From a084e1db3c3dedd92a30bf1d95c0b9cfea8f62b5 Mon Sep 17 00:00:00 2001 From: keithcurtis1 Date: Wed, 21 Jan 2026 00:35:53 -0800 Subject: [PATCH 01/16] Add files via upload --- TokenHome/1.0.0/TokenHome.js | 648 +++++++++++++++++++++++++++++++++++ TokenHome/TokenHome.js | 648 +++++++++++++++++++++++++++++++++++ TokenHome/readme.md | 162 +++++++++ TokenHome/script.json | 14 + 4 files changed, 1472 insertions(+) create mode 100644 TokenHome/1.0.0/TokenHome.js create mode 100644 TokenHome/TokenHome.js create mode 100644 TokenHome/readme.md create mode 100644 TokenHome/script.json diff --git a/TokenHome/1.0.0/TokenHome.js b/TokenHome/1.0.0/TokenHome.js new file mode 100644 index 000000000..79ffac6fb --- /dev/null +++ b/TokenHome/1.0.0/TokenHome.js @@ -0,0 +1,648 @@ +// 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', () => { + + const version = '1.0.0'; //version number set here + log('-=> TokenHome v' + version + ' is loaded. Use !home --help for documentation.'); + //1.0.0 Debut + + /***************** + * CONFIG + *****************/ + const STORAGE_ATTR = 'gmnotes'; + const HOME_BLOCK_REGEX = /
([\s\S]*?)<\/div>/i; + const LEGACY_HOME_REGEX = /
\s*home:\s*(-?\d+(?:\.\d*)?)\s*[,|]\s*(-?\d+(?:\.\d*)?)\s*<\/div>/i; + + const VALID_LAYERS = ['objects', 'map', 'gmlayer']; + const DEFAULT_LOCATION = 'L1'; + const DEFAULT_RADIUS = 300; + + 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.
  • +
  • --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#(integer). +

+ +

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#/code>
    + Restrict the summon to a specific stored location. +
  • +
  • + --radius|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 --L1
  • +
  • !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
  • +
+`; + + + /***************** + * UTILS + *****************/ + + function handleHelp(msg) { + if (msg.type !== "api") return; + + let handout = findObjs( + { + _type: "handout", + name: HOME_HELP_NAME + })[0]; + + if (!handout) { + handout = createObj("handout", + { + name: HOME_HELP_NAME, + archived: false + }); + handout.set("avatar", HOME_HELP_AVATAR); + } + + handout.set("notes", HOME_HELP_TEXT); + + const link = `http://journal.roll20.net/handout/${handout.get("_id")}`; + + const box = ` +
+
Token Home Help
+ Open Help Handout +
`.trim().replace(/\r?\n/g, ''); + + sendChat("Token Home", `/w gm ${box}`); + } + + + + + + const processInlinerolls = (msg) => { + if (!msg.inlinerolls) return msg.content; + return msg.inlinerolls + .reduce((m, v, k) => { + let ti = v.results.rolls.reduce((m2, v2) => { + if (v2.table) { + m2.push(v2.results.map(r => r.tableItem.name).join(', ')); + } + return m2; + }, []).join(', '); + return [...m, { k: `$[[${k}]]`, v: ti || v.results.total || 0 }]; + }, []) + .reduce((m, o) => m.replace(o.k, o.v), msg.content); + }; + + const keyFormat = (t) => (t && t.toLowerCase().replace(/\s+/g, '')) || undefined; + const isKeyMatch = (k, s) => s && s.includes(k); + const matchKey = (keys, subject) => + subject && keys.some(k => isKeyMatch(k, subject)); + + const getPageForPlayer = (playerid) => { + let player = getObj('player', playerid); + if (playerIsGM(playerid)) { + return player.get('lastpage') || Campaign().get('playerpageid'); + } + let psp = Campaign().get('playerspecificpages'); + return psp[playerid] || Campaign().get('playerpageid'); + }; + + const distance = (a, b) => + Math.hypot(a.left - b.left, a.top - b.top); + + /***************** + * STORAGE + *****************/ + const readGMNotes = (token) => unescape(token.get(STORAGE_ATTR) || ''); + const writeGMNotes = (token, text) => token.set(STORAGE_ATTR, escape(text)); + + const getStoredHomes = (token) => { + let notes = readGMNotes(token); + + let m = notes.match(HOME_BLOCK_REGEX); + if (m) { + try { + return JSON.parse(m[1]); + } catch (e) { + log(`TokenHomes: JSON parse failed on ${token.get('name')}`); + return {}; + } + } + + let legacy = notes.match(LEGACY_HOME_REGEX); + if (legacy) { + let homes = { + L1: { + left: Number(legacy[1]), + top: Number(legacy[2]), + layer: token.get('layer') + } + }; + saveHomes(token, homes, true); + return homes; + } + + return {}; + }; + + const saveHomes = (token, homes, removeLegacy = false) => { + let notes = readGMNotes(token); + notes = notes.replace(HOME_BLOCK_REGEX, ''); + if (removeLegacy) notes = notes.replace(LEGACY_HOME_REGEX, ''); + + let block = + `
` + + JSON.stringify(homes) + + `
`; + + writeGMNotes(token, notes + block); + }; + + const getHome = (token, loc) => { + let homes = getStoredHomes(token); + return homes[loc]; + }; + + const setHome = (token, loc) => { + let homes = getStoredHomes(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, true); + }; + + + const clearHome = (token, loc) => { + if (!token || !loc) return; + + let notes = readGMNotes(token); + let match = notes.match(HOME_BLOCK_REGEX); + if (!match) return; + + let homes; + try { + homes = JSON.parse(match[1]); + } catch (e) { + log(`TokenHome: JSON parse failed on ${token.get('name')}`); + return; + } + + if (!homes[loc]) return; + + delete homes[loc]; + + // Remove existing home block + notes = notes.replace(HOME_BLOCK_REGEX, ''); + + // If locations remain, re-save; otherwise leave block removed + if (Object.keys(homes).length) { + saveHomes(token, homes); + } else { + writeGMNotes(token, notes); + } + }; + + + const clearAllHomes = (token) => { + if (!token) return; + + let notes = readGMNotes(token); + if (!HOME_BLOCK_REGEX.test(notes)) return; + + notes = notes.replace(HOME_BLOCK_REGEX, ''); + writeGMNotes(token, notes); + }; + + + + + /***************** + * MOVE TOKEN + *****************/ + const moveToHome = (token, home) => { + token.set({ + left: home.left, + top: home.top, + layer: home.layer + }); + }; + + /***************** + * SUMMON LOGIC + *****************/ + 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; + + // Graphics and text + if (o._type === 'graphic' || o._type === 'text') { + return { + left: obj.get('left'), + top: obj.get('top'), + pageid: obj.get('pageid') + }; + } + + // Pins + if (o._type === 'pin') { + return { + left: obj.get('x'), + top: obj.get('y'), + pageid: obj.get('pageid') + }; + } + + // Doors and windows (line midpoint) + if (o._type === 'door' || o._type === 'window') { + const x = obj.get('x'); + const y = obj.get('y'); + const path = obj.get('path'); + + if (!path || !path.handle0 || !path.handle1) return null; + + const p0x = x + path.handle0.x; + const p0y = y + path.handle0.y; + const p1x = x + path.handle1.x; + const p1y = y + path.handle1.y; + + return { + left: (p0x + p1x) / 2, + top: (p0y + p1y) / (-2), + pageid: obj.get('pageid') + }; + } + + return null; + }; + + const findClosestHome = (homes, anchor, limitToLoc) => { + let best = null; + Object.entries(homes).forEach(([loc, home]) => { + if (limitToLoc && loc !== limitToLoc) return; + let d = distance(home, anchor); + if (!best || d < best.dist) { + best = { home, dist: d }; + } + }); + return best; + }; + + /***************** + * CHAT HANDLER + *****************/ + on('chat:message', (msg) => { + if ( + msg.type !== 'api' || + !/^!home(\b|\s)/i.test(msg.content) || + !playerIsGM(msg.playerid) + ) return; + + let who = (getObj('player', msg.playerid) || { get: () => 'API' }) + .get('_displayname'); + + let args = processInlinerolls(msg).split(/\s+--/).slice(1); + let flags = args.map(a => a.split(/\s+/)[0].toLowerCase()); + + // Help + if (flags.includes('help')) { + handleHelp(msg); + return; + } + + let location = null; + const locFlag = flags.find(f => /^l\d+$/.test(f)); + if (locFlag) location = locFlag.toUpperCase(); + + + let radius = DEFAULT_RADIUS; + let rArg = args.find(a => a.toLowerCase().startsWith('radius|')); + + if (rArg) { + let val = rArg.split('|')[1].toLowerCase(); + + if (val.endsWith('g')) { + let units = Number(val.slice(0, -1)); + if (!isNaN(units)) { + let pageid = getPageForPlayer(msg.playerid); + let page = getObj('page', pageid); + if (page) { + radius = units * 70 * (page.get('snapping_increment') || 1); + } + } + } else { + let px = Number(val); + if (!isNaN(px)) radius = px; + } + } + + + + let mode = + flags.includes('summon') ? 'summon' : + flags.includes('set') ? 'set' : + flags.includes('clear') ? 'clear' : + flags.includes('all') ? 'all' : + flags.includes('by-name') ? 'by-name' : + 'default'; + + let pid = getPageForPlayer(msg.playerid); + + const getSelectedTokens = () => + (msg.selected || []) + .map(o => getObj('graphic', o._id)) + .filter(Boolean); + + switch (mode) { + + case 'set': { + getSelectedTokens().forEach(t => setHome(t, location || DEFAULT_LOCATION)); + break; + } + + case 'all': { + findObjs({ type: 'graphic', pageid: pid }) + .forEach(t => { + let home = getHome(t, location || DEFAULT_LOCATION); + if (home) moveToHome(t, home); + }); + break; + } + + case 'by-name': { + let keys = args.slice(1).map(keyFormat).filter(Boolean); + if (!keys.length) { + sendChat('Token Home', `/w "${who}" Supply name fragments after --by-name`); + return; + } + + findObjs({ type: 'graphic', pageid: pid }) + .filter(t => matchKey(keys, keyFormat(t.get('name')))) + .forEach(t => { + let home = getHome(t, location || DEFAULT_LOCATION); + if (home) moveToHome(t, home); + }); + break; + } + + case 'summon': { + let anchor = getAnchorFromSelection(msg.selected); + if (!anchor || anchor.pageid !== pid) { + sendChat('Token Home', `/w "${who}" Select exactly one anchor on the current page.`); + return; + } + + findObjs({ type: 'graphic', pageid: pid }).forEach(t => { + let homes = getStoredHomes(t); + let closest = findClosestHome(homes, anchor, location); + if (closest && closest.dist <= radius) { + moveToHome(t, closest.home); + } + }); + break; + } + + case 'clear': { + let tokens = getSelectedTokens(); + if (!tokens.length) { + sendChat( + 'Token Home', + `/w "${who}" Select one or more tokens to clear stored locations.` + ); + return; + } + + tokens.forEach(t => { + if (location) { + clearHome(t, location); + } else { + clearAllHomes(t); + } + }); + break; + } + + + default: { + let tokens = getSelectedTokens(); + if (!tokens.length) { + sendChat('Token Home', + `/w "${who}" Usage: !home [--set|--all|--by-name|--summon] [--lN] [--r #]`); + return; + } + tokens.forEach(t => { + let home = getHome(t, location || DEFAULT_LOCATION); + if (home) moveToHome(t, home); + }); + } + } + }); +}); + +{ 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..79ffac6fb --- /dev/null +++ b/TokenHome/TokenHome.js @@ -0,0 +1,648 @@ +// 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', () => { + + const version = '1.0.0'; //version number set here + log('-=> TokenHome v' + version + ' is loaded. Use !home --help for documentation.'); + //1.0.0 Debut + + /***************** + * CONFIG + *****************/ + const STORAGE_ATTR = 'gmnotes'; + const HOME_BLOCK_REGEX = /
([\s\S]*?)<\/div>/i; + const LEGACY_HOME_REGEX = /
\s*home:\s*(-?\d+(?:\.\d*)?)\s*[,|]\s*(-?\d+(?:\.\d*)?)\s*<\/div>/i; + + const VALID_LAYERS = ['objects', 'map', 'gmlayer']; + const DEFAULT_LOCATION = 'L1'; + const DEFAULT_RADIUS = 300; + + 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.
  • +
  • --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#(integer). +

+ +

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#/code>
    + Restrict the summon to a specific stored location. +
  • +
  • + --radius|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 --L1
  • +
  • !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
  • +
+`; + + + /***************** + * UTILS + *****************/ + + function handleHelp(msg) { + if (msg.type !== "api") return; + + let handout = findObjs( + { + _type: "handout", + name: HOME_HELP_NAME + })[0]; + + if (!handout) { + handout = createObj("handout", + { + name: HOME_HELP_NAME, + archived: false + }); + handout.set("avatar", HOME_HELP_AVATAR); + } + + handout.set("notes", HOME_HELP_TEXT); + + const link = `http://journal.roll20.net/handout/${handout.get("_id")}`; + + const box = ` +
+
Token Home Help
+ Open Help Handout +
`.trim().replace(/\r?\n/g, ''); + + sendChat("Token Home", `/w gm ${box}`); + } + + + + + + const processInlinerolls = (msg) => { + if (!msg.inlinerolls) return msg.content; + return msg.inlinerolls + .reduce((m, v, k) => { + let ti = v.results.rolls.reduce((m2, v2) => { + if (v2.table) { + m2.push(v2.results.map(r => r.tableItem.name).join(', ')); + } + return m2; + }, []).join(', '); + return [...m, { k: `$[[${k}]]`, v: ti || v.results.total || 0 }]; + }, []) + .reduce((m, o) => m.replace(o.k, o.v), msg.content); + }; + + const keyFormat = (t) => (t && t.toLowerCase().replace(/\s+/g, '')) || undefined; + const isKeyMatch = (k, s) => s && s.includes(k); + const matchKey = (keys, subject) => + subject && keys.some(k => isKeyMatch(k, subject)); + + const getPageForPlayer = (playerid) => { + let player = getObj('player', playerid); + if (playerIsGM(playerid)) { + return player.get('lastpage') || Campaign().get('playerpageid'); + } + let psp = Campaign().get('playerspecificpages'); + return psp[playerid] || Campaign().get('playerpageid'); + }; + + const distance = (a, b) => + Math.hypot(a.left - b.left, a.top - b.top); + + /***************** + * STORAGE + *****************/ + const readGMNotes = (token) => unescape(token.get(STORAGE_ATTR) || ''); + const writeGMNotes = (token, text) => token.set(STORAGE_ATTR, escape(text)); + + const getStoredHomes = (token) => { + let notes = readGMNotes(token); + + let m = notes.match(HOME_BLOCK_REGEX); + if (m) { + try { + return JSON.parse(m[1]); + } catch (e) { + log(`TokenHomes: JSON parse failed on ${token.get('name')}`); + return {}; + } + } + + let legacy = notes.match(LEGACY_HOME_REGEX); + if (legacy) { + let homes = { + L1: { + left: Number(legacy[1]), + top: Number(legacy[2]), + layer: token.get('layer') + } + }; + saveHomes(token, homes, true); + return homes; + } + + return {}; + }; + + const saveHomes = (token, homes, removeLegacy = false) => { + let notes = readGMNotes(token); + notes = notes.replace(HOME_BLOCK_REGEX, ''); + if (removeLegacy) notes = notes.replace(LEGACY_HOME_REGEX, ''); + + let block = + `
` + + JSON.stringify(homes) + + `
`; + + writeGMNotes(token, notes + block); + }; + + const getHome = (token, loc) => { + let homes = getStoredHomes(token); + return homes[loc]; + }; + + const setHome = (token, loc) => { + let homes = getStoredHomes(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, true); + }; + + + const clearHome = (token, loc) => { + if (!token || !loc) return; + + let notes = readGMNotes(token); + let match = notes.match(HOME_BLOCK_REGEX); + if (!match) return; + + let homes; + try { + homes = JSON.parse(match[1]); + } catch (e) { + log(`TokenHome: JSON parse failed on ${token.get('name')}`); + return; + } + + if (!homes[loc]) return; + + delete homes[loc]; + + // Remove existing home block + notes = notes.replace(HOME_BLOCK_REGEX, ''); + + // If locations remain, re-save; otherwise leave block removed + if (Object.keys(homes).length) { + saveHomes(token, homes); + } else { + writeGMNotes(token, notes); + } + }; + + + const clearAllHomes = (token) => { + if (!token) return; + + let notes = readGMNotes(token); + if (!HOME_BLOCK_REGEX.test(notes)) return; + + notes = notes.replace(HOME_BLOCK_REGEX, ''); + writeGMNotes(token, notes); + }; + + + + + /***************** + * MOVE TOKEN + *****************/ + const moveToHome = (token, home) => { + token.set({ + left: home.left, + top: home.top, + layer: home.layer + }); + }; + + /***************** + * SUMMON LOGIC + *****************/ + 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; + + // Graphics and text + if (o._type === 'graphic' || o._type === 'text') { + return { + left: obj.get('left'), + top: obj.get('top'), + pageid: obj.get('pageid') + }; + } + + // Pins + if (o._type === 'pin') { + return { + left: obj.get('x'), + top: obj.get('y'), + pageid: obj.get('pageid') + }; + } + + // Doors and windows (line midpoint) + if (o._type === 'door' || o._type === 'window') { + const x = obj.get('x'); + const y = obj.get('y'); + const path = obj.get('path'); + + if (!path || !path.handle0 || !path.handle1) return null; + + const p0x = x + path.handle0.x; + const p0y = y + path.handle0.y; + const p1x = x + path.handle1.x; + const p1y = y + path.handle1.y; + + return { + left: (p0x + p1x) / 2, + top: (p0y + p1y) / (-2), + pageid: obj.get('pageid') + }; + } + + return null; + }; + + const findClosestHome = (homes, anchor, limitToLoc) => { + let best = null; + Object.entries(homes).forEach(([loc, home]) => { + if (limitToLoc && loc !== limitToLoc) return; + let d = distance(home, anchor); + if (!best || d < best.dist) { + best = { home, dist: d }; + } + }); + return best; + }; + + /***************** + * CHAT HANDLER + *****************/ + on('chat:message', (msg) => { + if ( + msg.type !== 'api' || + !/^!home(\b|\s)/i.test(msg.content) || + !playerIsGM(msg.playerid) + ) return; + + let who = (getObj('player', msg.playerid) || { get: () => 'API' }) + .get('_displayname'); + + let args = processInlinerolls(msg).split(/\s+--/).slice(1); + let flags = args.map(a => a.split(/\s+/)[0].toLowerCase()); + + // Help + if (flags.includes('help')) { + handleHelp(msg); + return; + } + + let location = null; + const locFlag = flags.find(f => /^l\d+$/.test(f)); + if (locFlag) location = locFlag.toUpperCase(); + + + let radius = DEFAULT_RADIUS; + let rArg = args.find(a => a.toLowerCase().startsWith('radius|')); + + if (rArg) { + let val = rArg.split('|')[1].toLowerCase(); + + if (val.endsWith('g')) { + let units = Number(val.slice(0, -1)); + if (!isNaN(units)) { + let pageid = getPageForPlayer(msg.playerid); + let page = getObj('page', pageid); + if (page) { + radius = units * 70 * (page.get('snapping_increment') || 1); + } + } + } else { + let px = Number(val); + if (!isNaN(px)) radius = px; + } + } + + + + let mode = + flags.includes('summon') ? 'summon' : + flags.includes('set') ? 'set' : + flags.includes('clear') ? 'clear' : + flags.includes('all') ? 'all' : + flags.includes('by-name') ? 'by-name' : + 'default'; + + let pid = getPageForPlayer(msg.playerid); + + const getSelectedTokens = () => + (msg.selected || []) + .map(o => getObj('graphic', o._id)) + .filter(Boolean); + + switch (mode) { + + case 'set': { + getSelectedTokens().forEach(t => setHome(t, location || DEFAULT_LOCATION)); + break; + } + + case 'all': { + findObjs({ type: 'graphic', pageid: pid }) + .forEach(t => { + let home = getHome(t, location || DEFAULT_LOCATION); + if (home) moveToHome(t, home); + }); + break; + } + + case 'by-name': { + let keys = args.slice(1).map(keyFormat).filter(Boolean); + if (!keys.length) { + sendChat('Token Home', `/w "${who}" Supply name fragments after --by-name`); + return; + } + + findObjs({ type: 'graphic', pageid: pid }) + .filter(t => matchKey(keys, keyFormat(t.get('name')))) + .forEach(t => { + let home = getHome(t, location || DEFAULT_LOCATION); + if (home) moveToHome(t, home); + }); + break; + } + + case 'summon': { + let anchor = getAnchorFromSelection(msg.selected); + if (!anchor || anchor.pageid !== pid) { + sendChat('Token Home', `/w "${who}" Select exactly one anchor on the current page.`); + return; + } + + findObjs({ type: 'graphic', pageid: pid }).forEach(t => { + let homes = getStoredHomes(t); + let closest = findClosestHome(homes, anchor, location); + if (closest && closest.dist <= radius) { + moveToHome(t, closest.home); + } + }); + break; + } + + case 'clear': { + let tokens = getSelectedTokens(); + if (!tokens.length) { + sendChat( + 'Token Home', + `/w "${who}" Select one or more tokens to clear stored locations.` + ); + return; + } + + tokens.forEach(t => { + if (location) { + clearHome(t, location); + } else { + clearAllHomes(t); + } + }); + break; + } + + + default: { + let tokens = getSelectedTokens(); + if (!tokens.length) { + sendChat('Token Home', + `/w "${who}" Usage: !home [--set|--all|--by-name|--summon] [--lN] [--r #]`); + return; + } + tokens.forEach(t => { + let home = getHome(t, location || DEFAULT_LOCATION); + if (home) moveToHome(t, home); + }); + } + } + }); +}); + +{ 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..7b67a6fa9 --- /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": [""] +} \ No newline at end of file From 6b894dc40dfa7b2db3fa9ec19c54040e7fcec47d Mon Sep 17 00:00:00 2001 From: keithcurtis1 Date: Wed, 21 Jan 2026 00:43:21 -0800 Subject: [PATCH 02/16] Update previousversions to include version 1.0.0 --- TokenHome/script.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/TokenHome/script.json b/TokenHome/script.json index 7b67a6fa9..b80695f22 100644 --- a/TokenHome/script.json +++ b/TokenHome/script.json @@ -10,5 +10,5 @@ "graphic": "write" }, "conflicts": [], - "previousversions": [""] -} \ No newline at end of file + "previousversions": ["1.0.0"] +} From 825d9b3cace14f2502be10dfc612a6f8154582cb Mon Sep 17 00:00:00 2001 From: keithcurtis1 Date: Sun, 25 Jan 2026 01:10:54 -0800 Subject: [PATCH 03/16] Add files via upload --- Fix Turnorder/fixTurnorder.js | 223 ++++++++++++++++++++++++++++++++++ Fix Turnorder/readme.md | 31 +++++ Fix Turnorder/script.json | 15 +++ 3 files changed, 269 insertions(+) create mode 100644 Fix Turnorder/fixTurnorder.js create mode 100644 Fix Turnorder/readme.md create mode 100644 Fix Turnorder/script.json diff --git a/Fix Turnorder/fixTurnorder.js b/Fix Turnorder/fixTurnorder.js new file mode 100644 index 000000000..2455b88b0 --- /dev/null +++ b/Fix Turnorder/fixTurnorder.js @@ -0,0 +1,223 @@ +// Script: Fix Turnorder +// 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('chat:message', (msg) => { + if (msg.type !== 'api') return; + if (!playerIsGM(msg.playerid)) return; + + const scriptName = 'FixTurnOrder'; + + /* ---------- 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:12px;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: "display:inline-block;margin-right:6px;padding:2px 6px;background:#a44;color:#eee;text-decoration:none;border-radius:4px;font-size:12px;", + 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;", + 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:12px;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:11px;" +}); + + + 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] === '--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 gmPage = getObj('page', gmPageId); + const playerPage = getObj('page', playerPageId); +sendStyledMessage( + 'Page Mismatch', + `You are viewing "${(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 += `
The following tokens are in the turn order, but not on the current player page:
`; +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 += `
`; + + + 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 Turnorder/readme.md b/Fix Turnorder/readme.md new file mode 100644 index 000000000..c2498585f --- /dev/null +++ b/Fix Turnorder/readme.md @@ -0,0 +1,31 @@ +New Script: +# FixTurnOrder + +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. + +**FixTurnOrder** 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 Turnorder/script.json b/Fix Turnorder/script.json new file mode 100644 index 000000000..179fac29b --- /dev/null +++ b/Fix Turnorder/script.json @@ -0,0 +1,15 @@ +{ + "name": "FixTurnOrder", + "script": "FixTurnOrder.js", + "version": "1.0.0", + "description": "# FixTurnOrder\n\nFixTurnOrder 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": [""] +} \ No newline at end of file From 2aa00a64f15261c2759bc703acee94e8e09f1c17 Mon Sep 17 00:00:00 2001 From: keithcurtis1 Date: Sun, 25 Jan 2026 12:17:03 -0800 Subject: [PATCH 04/16] Add files via upload --- Fix Turnorder/1.0.0/fixTurnorder.js | 268 ++++++++++++++++++++++++++++ Fix Turnorder/fixTurnorder.js | 69 +++++-- 2 files changed, 325 insertions(+), 12 deletions(-) create mode 100644 Fix Turnorder/1.0.0/fixTurnorder.js diff --git a/Fix Turnorder/1.0.0/fixTurnorder.js b/Fix Turnorder/1.0.0/fixTurnorder.js new file mode 100644 index 000000000..24e45da47 --- /dev/null +++ b/Fix Turnorder/1.0.0/fixTurnorder.js @@ -0,0 +1,268 @@ +// Script: Fix Turnorder +// 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('chat:message', (msg) => { + if (msg.type !== 'api') return; + if (!playerIsGM(msg.playerid)) return; + + const scriptName = 'FixTurnOrder'; + 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 + + + + + /* ---------- 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 Turnorder/fixTurnorder.js b/Fix Turnorder/fixTurnorder.js index 2455b88b0..24e45da47 100644 --- a/Fix Turnorder/fixTurnorder.js +++ b/Fix Turnorder/fixTurnorder.js @@ -10,6 +10,12 @@ on('chat:message', (msg) => { if (!playerIsGM(msg.playerid)) return; const scriptName = 'FixTurnOrder'; + 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 + + + /* ---------- helpers ---------- */ @@ -20,7 +26,7 @@ on('chat:message', (msg) => { `${char}`; const getCSS = () => ({ - box: "background:#bababa;border:2px solid #666;border-radius:8px;padding:8px;font-size:12px;color:#222;", + 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;", @@ -30,14 +36,15 @@ const getCSS = () => ({ 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: "display:inline-block;margin-right:6px;padding:2px 6px;background:#a44;color:#eee;text-decoration:none;border-radius:4px;font-size:12px;", + 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:12px;color:#222;", + 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:11px;" + messageButton: "padding:2px 6px;background:#777;color:#eee;text-decoration:none;border-radius:4px;font-size:14px;" }); @@ -103,6 +110,21 @@ if (!turnorderRaw || turnorderRaw === "") { 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')); @@ -139,14 +161,31 @@ if (!turnorderRaw || turnorderRaw === "") { /* ---------- page mismatch ---------- */ if (gmPageId !== playerPageId) { + const css = getCSS(); const gmPage = getObj('page', gmPageId); const playerPage = getObj('page', playerPageId); -sendStyledMessage( - 'Page Mismatch', - `You are viewing "${(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; - } + + + 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 ---------- */ @@ -186,7 +225,7 @@ const currentPageName = playerPage ? playerPage.get('name') : 'Unknown Page'; let html = `
`; -html += `
The following tokens are in the turn order, but not on the current player page:
`; +html += `
This is the active player page; the turn entries below it are from other pages.
`; html += `
${currentPageName}
`; pageIds.forEach(pid => { @@ -214,7 +253,13 @@ pageIds.forEach(pid => { html += `
`; }); -html += `
`; +html += + `
` + + `` + + `Clear all turns` + + `` + + `Check again?` + + `
`; sendHTML(html); From 36cc5393e6a1b439911e656a1ca57a3626279b06 Mon Sep 17 00:00:00 2001 From: keithcurtis1 Date: Sun, 25 Jan 2026 22:39:08 -0800 Subject: [PATCH 05/16] Delete Fix Turnorder directory --- Fix Turnorder/1.0.0/fixTurnorder.js | 268 ---------------------------- Fix Turnorder/fixTurnorder.js | 268 ---------------------------- Fix Turnorder/readme.md | 31 ---- Fix Turnorder/script.json | 15 -- 4 files changed, 582 deletions(-) delete mode 100644 Fix Turnorder/1.0.0/fixTurnorder.js delete mode 100644 Fix Turnorder/fixTurnorder.js delete mode 100644 Fix Turnorder/readme.md delete mode 100644 Fix Turnorder/script.json diff --git a/Fix Turnorder/1.0.0/fixTurnorder.js b/Fix Turnorder/1.0.0/fixTurnorder.js deleted file mode 100644 index 24e45da47..000000000 --- a/Fix Turnorder/1.0.0/fixTurnorder.js +++ /dev/null @@ -1,268 +0,0 @@ -// Script: Fix Turnorder -// 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('chat:message', (msg) => { - if (msg.type !== 'api') return; - if (!playerIsGM(msg.playerid)) return; - - const scriptName = 'FixTurnOrder'; - 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 - - - - - /* ---------- 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 Turnorder/fixTurnorder.js b/Fix Turnorder/fixTurnorder.js deleted file mode 100644 index 24e45da47..000000000 --- a/Fix Turnorder/fixTurnorder.js +++ /dev/null @@ -1,268 +0,0 @@ -// Script: Fix Turnorder -// 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('chat:message', (msg) => { - if (msg.type !== 'api') return; - if (!playerIsGM(msg.playerid)) return; - - const scriptName = 'FixTurnOrder'; - 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 - - - - - /* ---------- 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 Turnorder/readme.md b/Fix Turnorder/readme.md deleted file mode 100644 index c2498585f..000000000 --- a/Fix Turnorder/readme.md +++ /dev/null @@ -1,31 +0,0 @@ -New Script: -# FixTurnOrder - -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. - -**FixTurnOrder** 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 Turnorder/script.json b/Fix Turnorder/script.json deleted file mode 100644 index 179fac29b..000000000 --- a/Fix Turnorder/script.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "FixTurnOrder", - "script": "FixTurnOrder.js", - "version": "1.0.0", - "description": "# FixTurnOrder\n\nFixTurnOrder 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": [""] -} \ No newline at end of file From 704ee6506619d6cbcdc32a7f2c17b3a1d54df4fe Mon Sep 17 00:00:00 2001 From: keithcurtis1 Date: Sun, 25 Jan 2026 22:41:18 -0800 Subject: [PATCH 06/16] Add files via upload --- Fix Turn Order/1.0.0/fixTurnOrder.js | 273 +++++++++++++++++++++++++++ Fix Turn Order/fixTurnOrder.js | 273 +++++++++++++++++++++++++++ Fix Turn Order/readme.md | 31 +++ Fix Turn Order/script.json | 15 ++ 4 files changed, 592 insertions(+) create mode 100644 Fix Turn Order/1.0.0/fixTurnOrder.js create mode 100644 Fix Turn Order/fixTurnOrder.js create mode 100644 Fix Turn Order/readme.md create mode 100644 Fix Turn Order/script.json 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..f7bd05874 --- /dev/null +++ b/Fix Turn Order/1.0.0/fixTurnOrder.js @@ -0,0 +1,273 @@ +// 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', () => { + +on('chat:message', (msg) => { + if (msg.type !== 'api') return; + if (!playerIsGM(msg.playerid)) return; + + 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 + + + + + /* ---------- 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..f7bd05874 --- /dev/null +++ b/Fix Turn Order/fixTurnOrder.js @@ -0,0 +1,273 @@ +// 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', () => { + +on('chat:message', (msg) => { + if (msg.type !== 'api') return; + if (!playerIsGM(msg.playerid)) return; + + 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 + + + + + /* ---------- 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 From 3a674e693e68b75746af4b9f77bdc2f153da8991 Mon Sep 17 00:00:00 2001 From: keithcurtis1 Date: Sun, 25 Jan 2026 23:32:31 -0800 Subject: [PATCH 07/16] Reintroduce chat message listener for API messages --- Fix Turn Order/fixTurnOrder.js | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/Fix Turn Order/fixTurnOrder.js b/Fix Turn Order/fixTurnOrder.js index f7bd05874..b55dc4eff 100644 --- a/Fix Turn Order/fixTurnOrder.js +++ b/Fix Turn Order/fixTurnOrder.js @@ -9,10 +9,6 @@ API_Meta.fixTurnOrder={offset:Number.MAX_SAFE_INTEGER,lineCount:-1}; on('ready', () => { -on('chat:message', (msg) => { - if (msg.type !== 'api') return; - if (!playerIsGM(msg.playerid)) return; - 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.'); @@ -20,6 +16,13 @@ on('chat:message', (msg) => { +on('chat:message', (msg) => { + if (msg.type !== 'api') return; + if (!playerIsGM(msg.playerid)) return; + + + + /* ---------- helpers ---------- */ From 8b0e1abfac0696e2ddd9951389f128e2012f1d2e Mon Sep 17 00:00:00 2001 From: keithcurtis1 Date: Sun, 25 Jan 2026 23:32:52 -0800 Subject: [PATCH 08/16] Refactor chat message event handler --- Fix Turn Order/1.0.0/fixTurnOrder.js | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/Fix Turn Order/1.0.0/fixTurnOrder.js b/Fix Turn Order/1.0.0/fixTurnOrder.js index f7bd05874..b55dc4eff 100644 --- a/Fix Turn Order/1.0.0/fixTurnOrder.js +++ b/Fix Turn Order/1.0.0/fixTurnOrder.js @@ -9,10 +9,6 @@ API_Meta.fixTurnOrder={offset:Number.MAX_SAFE_INTEGER,lineCount:-1}; on('ready', () => { -on('chat:message', (msg) => { - if (msg.type !== 'api') return; - if (!playerIsGM(msg.playerid)) return; - 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.'); @@ -20,6 +16,13 @@ on('chat:message', (msg) => { +on('chat:message', (msg) => { + if (msg.type !== 'api') return; + if (!playerIsGM(msg.playerid)) return; + + + + /* ---------- helpers ---------- */ From 4e395c699d63cc4efd9074049c2b27ebf210682b Mon Sep 17 00:00:00 2001 From: keithcurtis1 Date: Mon, 26 Jan 2026 14:04:07 -0800 Subject: [PATCH 09/16] Refactor TokenHome script for improved structure --- TokenHome/TokenHome.js | 700 +++++++---------------------------------- 1 file changed, 121 insertions(+), 579 deletions(-) diff --git a/TokenHome/TokenHome.js b/TokenHome/TokenHome.js index 79ffac6fb..b1a19b5fb 100644 --- a/TokenHome/TokenHome.js +++ b/TokenHome/TokenHome.js @@ -1,370 +1,94 @@ -// 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', () => { - const version = '1.0.0'; //version number set here - log('-=> TokenHome v' + version + ' is loaded. Use !home --help for documentation.'); - //1.0.0 Debut - - /***************** + /************************* * CONFIG - *****************/ + *************************/ const STORAGE_ATTR = 'gmnotes'; - const HOME_BLOCK_REGEX = /
([\s\S]*?)<\/div>/i; - const LEGACY_HOME_REGEX = /
\s*home:\s*(-?\d+(?:\.\d*)?)\s*[,|]\s*(-?\d+(?:\.\d*)?)\s*<\/div>/i; - - const VALID_LAYERS = ['objects', 'map', 'gmlayer']; - const DEFAULT_LOCATION = 'L1'; - const DEFAULT_RADIUS = 300; - - 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.
  • -
  • --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#(integer). -

- -

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#/code>
    - Restrict the summon to a specific stored location. -
  • -
  • - --radius|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 --L1
  • -
  • !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
  • -
-`; - - - /***************** - * UTILS - *****************/ - - function handleHelp(msg) { - if (msg.type !== "api") return; - - let handout = findObjs( - { - _type: "handout", - name: HOME_HELP_NAME - })[0]; - - if (!handout) { - handout = createObj("handout", - { - name: HOME_HELP_NAME, - archived: false - }); - handout.set("avatar", HOME_HELP_AVATAR); - } - - handout.set("notes", HOME_HELP_TEXT); - - const link = `http://journal.roll20.net/handout/${handout.get("_id")}`; - - const box = ` -
-
Token Home Help
- Open Help Handout -
`.trim().replace(/\r?\n/g, ''); + const DEFAULT_LOC = 'L1'; + const VALID_LAYERS = ['objects', 'map', 'gmlayer', 'walls']; - sendChat("Token Home", `/w gm ${box}`); - } + /************************* + * REGEX + *************************/ + // Entire hidden storage block + const HOME_BLOCK_REGEX = + /
\s*TOKENHOME([\s\S]*?)<\/div>/i; + // Individual home lines: L1:123,456,objects + const HOME_LINE_REGEX = + /^\s*(L\d+)\s*:\s*(-?\d+(?:\.\d*)?)\s*,\s*(-?\d+(?:\.\d*)?)\s*,\s*(\w+)\s*$/gim; + /************************* + * LOW-LEVEL HELPERS + *************************/ + + const extractLocation = (args) => { + const locArg = args.find(a => /^L\d+$/i.test(a)); + return (locArg || DEFAULT_LOC).toUpperCase(); +}; - const processInlinerolls = (msg) => { - if (!msg.inlinerolls) return msg.content; - return msg.inlinerolls - .reduce((m, v, k) => { - let ti = v.results.rolls.reduce((m2, v2) => { - if (v2.table) { - m2.push(v2.results.map(r => r.tableItem.name).join(', ')); - } - return m2; - }, []).join(', '); - return [...m, { k: `$[[${k}]]`, v: ti || v.results.total || 0 }]; - }, []) - .reduce((m, o) => m.replace(o.k, o.v), msg.content); - }; - - const keyFormat = (t) => (t && t.toLowerCase().replace(/\s+/g, '')) || undefined; - const isKeyMatch = (k, s) => s && s.includes(k); - const matchKey = (keys, subject) => - subject && keys.some(k => isKeyMatch(k, subject)); + const readNotes = (token) => + unescape(token.get(STORAGE_ATTR) || ''); - const getPageForPlayer = (playerid) => { - let player = getObj('player', playerid); - if (playerIsGM(playerid)) { - return player.get('lastpage') || Campaign().get('playerpageid'); - } - let psp = Campaign().get('playerspecificpages'); - return psp[playerid] || Campaign().get('playerpageid'); - }; + 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 readGMNotes = (token) => unescape(token.get(STORAGE_ATTR) || ''); - const writeGMNotes = (token, text) => token.set(STORAGE_ATTR, escape(text)); - - const getStoredHomes = (token) => { - let notes = readGMNotes(token); - - let m = notes.match(HOME_BLOCK_REGEX); - if (m) { - try { - return JSON.parse(m[1]); - } catch (e) { - log(`TokenHomes: JSON parse failed on ${token.get('name')}`); - return {}; - } - } - - let legacy = notes.match(LEGACY_HOME_REGEX); - if (legacy) { - let homes = { - L1: { - left: Number(legacy[1]), - top: Number(legacy[2]), - layer: token.get('layer') - } + *************************/ + + const getHomes = (token) => { + const 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' }; - saveHomes(token, homes, true); - return homes; } - return {}; + return homes; }; - const saveHomes = (token, homes, removeLegacy = false) => { - let notes = readGMNotes(token); + const saveHomes = (token, homes) => { + let notes = readNotes(token); + + // Strip old block entirely notes = notes.replace(HOME_BLOCK_REGEX, ''); - if (removeLegacy) notes = notes.replace(LEGACY_HOME_REGEX, ''); - let block = - `
` + - JSON.stringify(homes) + - `
`; + const lines = Object.entries(homes) + .map(([loc, h]) => + `${loc}:${h.left},${h.top},${h.layer}` + ) + .join('\n'); - writeGMNotes(token, notes + block); - }; + if (!lines.trim()) { + writeNotes(token, notes); + return; + } - const getHome = (token, loc) => { - let homes = getStoredHomes(token); - return homes[loc]; + const block = +`
+TOKENHOME +${lines} +
`; + + writeNotes(token, notes + block); }; const setHome = (token, loc) => { - let homes = getStoredHomes(token); + const homes = getHomes(token); + homes[loc] = { left: token.get('left'), top: token.get('top'), @@ -372,277 +96,95 @@ The selected object’s X/Y position is used as the summon target. ? token.get('layer') : 'objects' }; - saveHomes(token, homes, true); - }; - - - const clearHome = (token, loc) => { - if (!token || !loc) return; - - let notes = readGMNotes(token); - let match = notes.match(HOME_BLOCK_REGEX); - if (!match) return; - - let homes; - try { - homes = JSON.parse(match[1]); - } catch (e) { - log(`TokenHome: JSON parse failed on ${token.get('name')}`); - return; - } - - if (!homes[loc]) return; - delete homes[loc]; - - // Remove existing home block - notes = notes.replace(HOME_BLOCK_REGEX, ''); - - // If locations remain, re-save; otherwise leave block removed - if (Object.keys(homes).length) { - saveHomes(token, homes); - } else { - writeGMNotes(token, notes); - } + saveHomes(token, homes); }; - - const clearAllHomes = (token) => { - if (!token) return; - - let notes = readGMNotes(token); - if (!HOME_BLOCK_REGEX.test(notes)) return; - - notes = notes.replace(HOME_BLOCK_REGEX, ''); - writeGMNotes(token, notes); - }; - - - - - /***************** - * MOVE TOKEN - *****************/ - const moveToHome = (token, home) => { - token.set({ - left: home.left, - top: home.top, - layer: home.layer - }); + const getHome = (token, loc) => { + return getHomes(token)[loc]; }; - /***************** - * SUMMON LOGIC - *****************/ - 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; - - // Graphics and text - if (o._type === 'graphic' || o._type === 'text') { - return { - left: obj.get('left'), - top: obj.get('top'), - pageid: obj.get('pageid') - }; - } - - // Pins - if (o._type === 'pin') { - return { - left: obj.get('x'), - top: obj.get('y'), - pageid: obj.get('pageid') - }; - } - - // Doors and windows (line midpoint) - if (o._type === 'door' || o._type === 'window') { - const x = obj.get('x'); - const y = obj.get('y'); - const path = obj.get('path'); + /************************* + * PAGE HELPERS + *************************/ - if (!path || !path.handle0 || !path.handle1) return null; - - const p0x = x + path.handle0.x; - const p0y = y + path.handle0.y; - const p1x = x + path.handle1.x; - const p1y = y + path.handle1.y; - - return { - left: (p0x + p1x) / 2, - top: (p0y + p1y) / (-2), - pageid: obj.get('pageid') - }; + const getPageForPlayer = (playerid) => { + if (playerIsGM(playerid)) { + return Campaign().get('playerpageid'); } - return null; + const psp = Campaign().get('playerspecificpages'); + return psp[playerid] || Campaign().get('playerpageid'); }; - const findClosestHome = (homes, anchor, limitToLoc) => { - let best = null; - Object.entries(homes).forEach(([loc, home]) => { - if (limitToLoc && loc !== limitToLoc) return; - let d = distance(home, anchor); - if (!best || d < best.dist) { - best = { home, dist: d }; - } - }); - return best; - }; + /************************* + * CHAT COMMAND + *************************/ - /***************** - * CHAT HANDLER - *****************/ on('chat:message', (msg) => { - if ( - msg.type !== 'api' || - !/^!home(\b|\s)/i.test(msg.content) || - !playerIsGM(msg.playerid) - ) return; + if (msg.type !== 'api' || !/^!home\b/i.test(msg.content)) return; + if (!playerIsGM(msg.playerid)) return; - let who = (getObj('player', msg.playerid) || { get: () => 'API' }) - .get('_displayname'); + const args = msg.content.split(/\s+--/).slice(1); +let sub = (args[0] || '').trim().toLowerCase(); - let args = processInlinerolls(msg).split(/\s+--/).slice(1); - let flags = args.map(a => a.split(/\s+/)[0].toLowerCase()); +// If the first argument is a location (L#), it is NOT a subcommand +if (/^l\d+$/i.test(sub)) { + sub = ''; +} else { + args.shift(); +} - // Help - if (flags.includes('help')) { - handleHelp(msg); - return; - } - - let location = null; - const locFlag = flags.find(f => /^l\d+$/.test(f)); - if (locFlag) location = locFlag.toUpperCase(); - - - let radius = DEFAULT_RADIUS; - let rArg = args.find(a => a.toLowerCase().startsWith('radius|')); - - if (rArg) { - let val = rArg.split('|')[1].toLowerCase(); - - if (val.endsWith('g')) { - let units = Number(val.slice(0, -1)); - if (!isNaN(units)) { - let pageid = getPageForPlayer(msg.playerid); - let page = getObj('page', pageid); - if (page) { - radius = units * 70 * (page.get('snapping_increment') || 1); - } - } - } else { - let px = Number(val); - if (!isNaN(px)) radius = px; - } - } +const loc = extractLocation(args.concat(sub)); + const pageid = getPageForPlayer(msg.playerid); + const page = getObj('page', pageid); + const grid = 70 * (page.get('snapping_increment') || 1); + const half = grid / 2; + const maxX = page.get('width') * grid; + const maxY = page.get('height') * grid; - let mode = - flags.includes('summon') ? 'summon' : - flags.includes('set') ? 'set' : - flags.includes('clear') ? 'clear' : - flags.includes('all') ? 'all' : - flags.includes('by-name') ? 'by-name' : - 'default'; + const clamp = (v, max) => Math.max(half, Math.min(v, max - half)); - let pid = getPageForPlayer(msg.playerid); + const selected = (msg.selected || []) + .map(o => getObj('graphic', o._id)) + .filter(Boolean); - const getSelectedTokens = () => - (msg.selected || []) - .map(o => getObj('graphic', o._id)) - .filter(Boolean); - - switch (mode) { + switch (sub) { case 'set': { - getSelectedTokens().forEach(t => setHome(t, location || DEFAULT_LOCATION)); + selected.forEach(t => setHome(t, loc)); break; } case 'all': { - findObjs({ type: 'graphic', pageid: pid }) + findObjs({ type: 'graphic', pageid }) .forEach(t => { - let home = getHome(t, location || DEFAULT_LOCATION); - if (home) moveToHome(t, home); + const h = getHome(t, loc); + if (!h) return; + + t.set({ + left: clamp(h.left, maxX), + top: clamp(h.top, maxY), + layer: h.layer + }); }); break; } - case 'by-name': { - let keys = args.slice(1).map(keyFormat).filter(Boolean); - if (!keys.length) { - sendChat('Token Home', `/w "${who}" Supply name fragments after --by-name`); - return; - } - - findObjs({ type: 'graphic', pageid: pid }) - .filter(t => matchKey(keys, keyFormat(t.get('name')))) - .forEach(t => { - let home = getHome(t, location || DEFAULT_LOCATION); - if (home) moveToHome(t, home); - }); - break; - } - - case 'summon': { - let anchor = getAnchorFromSelection(msg.selected); - if (!anchor || anchor.pageid !== pid) { - sendChat('Token Home', `/w "${who}" Select exactly one anchor on the current page.`); - return; - } - - findObjs({ type: 'graphic', pageid: pid }).forEach(t => { - let homes = getStoredHomes(t); - let closest = findClosestHome(homes, anchor, location); - if (closest && closest.dist <= radius) { - moveToHome(t, closest.home); - } - }); - break; - } - - case 'clear': { - let tokens = getSelectedTokens(); - if (!tokens.length) { - sendChat( - 'Token Home', - `/w "${who}" Select one or more tokens to clear stored locations.` - ); - return; - } - - tokens.forEach(t => { - if (location) { - clearHome(t, location); - } else { - clearAllHomes(t); - } - }); - break; - } - - default: { - let tokens = getSelectedTokens(); - if (!tokens.length) { - sendChat('Token Home', - `/w "${who}" Usage: !home [--set|--all|--by-name|--summon] [--lN] [--r #]`); - return; - } - tokens.forEach(t => { - let home = getHome(t, location || DEFAULT_LOCATION); - if (home) moveToHome(t, home); + selected.forEach(t => { + const h = getHome(t, loc); + if (!h) return; + + t.set({ + left: clamp(h.left, maxX), + top: clamp(h.top, maxY), + 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); } } From 2e061857094311c63be7b4b417365d79021f72eb Mon Sep 17 00:00:00 2001 From: keithcurtis1 Date: Mon, 26 Jan 2026 14:04:33 -0800 Subject: [PATCH 10/16] Refactor TokenHome script for improved readability --- TokenHome/1.0.0/TokenHome.js | 700 ++++++----------------------------- 1 file changed, 121 insertions(+), 579 deletions(-) diff --git a/TokenHome/1.0.0/TokenHome.js b/TokenHome/1.0.0/TokenHome.js index 79ffac6fb..b1a19b5fb 100644 --- a/TokenHome/1.0.0/TokenHome.js +++ b/TokenHome/1.0.0/TokenHome.js @@ -1,370 +1,94 @@ -// 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', () => { - const version = '1.0.0'; //version number set here - log('-=> TokenHome v' + version + ' is loaded. Use !home --help for documentation.'); - //1.0.0 Debut - - /***************** + /************************* * CONFIG - *****************/ + *************************/ const STORAGE_ATTR = 'gmnotes'; - const HOME_BLOCK_REGEX = /
([\s\S]*?)<\/div>/i; - const LEGACY_HOME_REGEX = /
\s*home:\s*(-?\d+(?:\.\d*)?)\s*[,|]\s*(-?\d+(?:\.\d*)?)\s*<\/div>/i; - - const VALID_LAYERS = ['objects', 'map', 'gmlayer']; - const DEFAULT_LOCATION = 'L1'; - const DEFAULT_RADIUS = 300; - - 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.
  • -
  • --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#(integer). -

- -

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#/code>
    - Restrict the summon to a specific stored location. -
  • -
  • - --radius|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 --L1
  • -
  • !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
  • -
-`; - - - /***************** - * UTILS - *****************/ - - function handleHelp(msg) { - if (msg.type !== "api") return; - - let handout = findObjs( - { - _type: "handout", - name: HOME_HELP_NAME - })[0]; - - if (!handout) { - handout = createObj("handout", - { - name: HOME_HELP_NAME, - archived: false - }); - handout.set("avatar", HOME_HELP_AVATAR); - } - - handout.set("notes", HOME_HELP_TEXT); - - const link = `http://journal.roll20.net/handout/${handout.get("_id")}`; - - const box = ` -
-
Token Home Help
- Open Help Handout -
`.trim().replace(/\r?\n/g, ''); + const DEFAULT_LOC = 'L1'; + const VALID_LAYERS = ['objects', 'map', 'gmlayer', 'walls']; - sendChat("Token Home", `/w gm ${box}`); - } + /************************* + * REGEX + *************************/ + // Entire hidden storage block + const HOME_BLOCK_REGEX = + /
\s*TOKENHOME([\s\S]*?)<\/div>/i; + // Individual home lines: L1:123,456,objects + const HOME_LINE_REGEX = + /^\s*(L\d+)\s*:\s*(-?\d+(?:\.\d*)?)\s*,\s*(-?\d+(?:\.\d*)?)\s*,\s*(\w+)\s*$/gim; + /************************* + * LOW-LEVEL HELPERS + *************************/ + + const extractLocation = (args) => { + const locArg = args.find(a => /^L\d+$/i.test(a)); + return (locArg || DEFAULT_LOC).toUpperCase(); +}; - const processInlinerolls = (msg) => { - if (!msg.inlinerolls) return msg.content; - return msg.inlinerolls - .reduce((m, v, k) => { - let ti = v.results.rolls.reduce((m2, v2) => { - if (v2.table) { - m2.push(v2.results.map(r => r.tableItem.name).join(', ')); - } - return m2; - }, []).join(', '); - return [...m, { k: `$[[${k}]]`, v: ti || v.results.total || 0 }]; - }, []) - .reduce((m, o) => m.replace(o.k, o.v), msg.content); - }; - - const keyFormat = (t) => (t && t.toLowerCase().replace(/\s+/g, '')) || undefined; - const isKeyMatch = (k, s) => s && s.includes(k); - const matchKey = (keys, subject) => - subject && keys.some(k => isKeyMatch(k, subject)); + const readNotes = (token) => + unescape(token.get(STORAGE_ATTR) || ''); - const getPageForPlayer = (playerid) => { - let player = getObj('player', playerid); - if (playerIsGM(playerid)) { - return player.get('lastpage') || Campaign().get('playerpageid'); - } - let psp = Campaign().get('playerspecificpages'); - return psp[playerid] || Campaign().get('playerpageid'); - }; + 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 readGMNotes = (token) => unescape(token.get(STORAGE_ATTR) || ''); - const writeGMNotes = (token, text) => token.set(STORAGE_ATTR, escape(text)); - - const getStoredHomes = (token) => { - let notes = readGMNotes(token); - - let m = notes.match(HOME_BLOCK_REGEX); - if (m) { - try { - return JSON.parse(m[1]); - } catch (e) { - log(`TokenHomes: JSON parse failed on ${token.get('name')}`); - return {}; - } - } - - let legacy = notes.match(LEGACY_HOME_REGEX); - if (legacy) { - let homes = { - L1: { - left: Number(legacy[1]), - top: Number(legacy[2]), - layer: token.get('layer') - } + *************************/ + + const getHomes = (token) => { + const 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' }; - saveHomes(token, homes, true); - return homes; } - return {}; + return homes; }; - const saveHomes = (token, homes, removeLegacy = false) => { - let notes = readGMNotes(token); + const saveHomes = (token, homes) => { + let notes = readNotes(token); + + // Strip old block entirely notes = notes.replace(HOME_BLOCK_REGEX, ''); - if (removeLegacy) notes = notes.replace(LEGACY_HOME_REGEX, ''); - let block = - `
` + - JSON.stringify(homes) + - `
`; + const lines = Object.entries(homes) + .map(([loc, h]) => + `${loc}:${h.left},${h.top},${h.layer}` + ) + .join('\n'); - writeGMNotes(token, notes + block); - }; + if (!lines.trim()) { + writeNotes(token, notes); + return; + } - const getHome = (token, loc) => { - let homes = getStoredHomes(token); - return homes[loc]; + const block = +`
+TOKENHOME +${lines} +
`; + + writeNotes(token, notes + block); }; const setHome = (token, loc) => { - let homes = getStoredHomes(token); + const homes = getHomes(token); + homes[loc] = { left: token.get('left'), top: token.get('top'), @@ -372,277 +96,95 @@ The selected object’s X/Y position is used as the summon target. ? token.get('layer') : 'objects' }; - saveHomes(token, homes, true); - }; - - - const clearHome = (token, loc) => { - if (!token || !loc) return; - - let notes = readGMNotes(token); - let match = notes.match(HOME_BLOCK_REGEX); - if (!match) return; - - let homes; - try { - homes = JSON.parse(match[1]); - } catch (e) { - log(`TokenHome: JSON parse failed on ${token.get('name')}`); - return; - } - - if (!homes[loc]) return; - delete homes[loc]; - - // Remove existing home block - notes = notes.replace(HOME_BLOCK_REGEX, ''); - - // If locations remain, re-save; otherwise leave block removed - if (Object.keys(homes).length) { - saveHomes(token, homes); - } else { - writeGMNotes(token, notes); - } + saveHomes(token, homes); }; - - const clearAllHomes = (token) => { - if (!token) return; - - let notes = readGMNotes(token); - if (!HOME_BLOCK_REGEX.test(notes)) return; - - notes = notes.replace(HOME_BLOCK_REGEX, ''); - writeGMNotes(token, notes); - }; - - - - - /***************** - * MOVE TOKEN - *****************/ - const moveToHome = (token, home) => { - token.set({ - left: home.left, - top: home.top, - layer: home.layer - }); + const getHome = (token, loc) => { + return getHomes(token)[loc]; }; - /***************** - * SUMMON LOGIC - *****************/ - 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; - - // Graphics and text - if (o._type === 'graphic' || o._type === 'text') { - return { - left: obj.get('left'), - top: obj.get('top'), - pageid: obj.get('pageid') - }; - } - - // Pins - if (o._type === 'pin') { - return { - left: obj.get('x'), - top: obj.get('y'), - pageid: obj.get('pageid') - }; - } - - // Doors and windows (line midpoint) - if (o._type === 'door' || o._type === 'window') { - const x = obj.get('x'); - const y = obj.get('y'); - const path = obj.get('path'); + /************************* + * PAGE HELPERS + *************************/ - if (!path || !path.handle0 || !path.handle1) return null; - - const p0x = x + path.handle0.x; - const p0y = y + path.handle0.y; - const p1x = x + path.handle1.x; - const p1y = y + path.handle1.y; - - return { - left: (p0x + p1x) / 2, - top: (p0y + p1y) / (-2), - pageid: obj.get('pageid') - }; + const getPageForPlayer = (playerid) => { + if (playerIsGM(playerid)) { + return Campaign().get('playerpageid'); } - return null; + const psp = Campaign().get('playerspecificpages'); + return psp[playerid] || Campaign().get('playerpageid'); }; - const findClosestHome = (homes, anchor, limitToLoc) => { - let best = null; - Object.entries(homes).forEach(([loc, home]) => { - if (limitToLoc && loc !== limitToLoc) return; - let d = distance(home, anchor); - if (!best || d < best.dist) { - best = { home, dist: d }; - } - }); - return best; - }; + /************************* + * CHAT COMMAND + *************************/ - /***************** - * CHAT HANDLER - *****************/ on('chat:message', (msg) => { - if ( - msg.type !== 'api' || - !/^!home(\b|\s)/i.test(msg.content) || - !playerIsGM(msg.playerid) - ) return; + if (msg.type !== 'api' || !/^!home\b/i.test(msg.content)) return; + if (!playerIsGM(msg.playerid)) return; - let who = (getObj('player', msg.playerid) || { get: () => 'API' }) - .get('_displayname'); + const args = msg.content.split(/\s+--/).slice(1); +let sub = (args[0] || '').trim().toLowerCase(); - let args = processInlinerolls(msg).split(/\s+--/).slice(1); - let flags = args.map(a => a.split(/\s+/)[0].toLowerCase()); +// If the first argument is a location (L#), it is NOT a subcommand +if (/^l\d+$/i.test(sub)) { + sub = ''; +} else { + args.shift(); +} - // Help - if (flags.includes('help')) { - handleHelp(msg); - return; - } - - let location = null; - const locFlag = flags.find(f => /^l\d+$/.test(f)); - if (locFlag) location = locFlag.toUpperCase(); - - - let radius = DEFAULT_RADIUS; - let rArg = args.find(a => a.toLowerCase().startsWith('radius|')); - - if (rArg) { - let val = rArg.split('|')[1].toLowerCase(); - - if (val.endsWith('g')) { - let units = Number(val.slice(0, -1)); - if (!isNaN(units)) { - let pageid = getPageForPlayer(msg.playerid); - let page = getObj('page', pageid); - if (page) { - radius = units * 70 * (page.get('snapping_increment') || 1); - } - } - } else { - let px = Number(val); - if (!isNaN(px)) radius = px; - } - } +const loc = extractLocation(args.concat(sub)); + const pageid = getPageForPlayer(msg.playerid); + const page = getObj('page', pageid); + const grid = 70 * (page.get('snapping_increment') || 1); + const half = grid / 2; + const maxX = page.get('width') * grid; + const maxY = page.get('height') * grid; - let mode = - flags.includes('summon') ? 'summon' : - flags.includes('set') ? 'set' : - flags.includes('clear') ? 'clear' : - flags.includes('all') ? 'all' : - flags.includes('by-name') ? 'by-name' : - 'default'; + const clamp = (v, max) => Math.max(half, Math.min(v, max - half)); - let pid = getPageForPlayer(msg.playerid); + const selected = (msg.selected || []) + .map(o => getObj('graphic', o._id)) + .filter(Boolean); - const getSelectedTokens = () => - (msg.selected || []) - .map(o => getObj('graphic', o._id)) - .filter(Boolean); - - switch (mode) { + switch (sub) { case 'set': { - getSelectedTokens().forEach(t => setHome(t, location || DEFAULT_LOCATION)); + selected.forEach(t => setHome(t, loc)); break; } case 'all': { - findObjs({ type: 'graphic', pageid: pid }) + findObjs({ type: 'graphic', pageid }) .forEach(t => { - let home = getHome(t, location || DEFAULT_LOCATION); - if (home) moveToHome(t, home); + const h = getHome(t, loc); + if (!h) return; + + t.set({ + left: clamp(h.left, maxX), + top: clamp(h.top, maxY), + layer: h.layer + }); }); break; } - case 'by-name': { - let keys = args.slice(1).map(keyFormat).filter(Boolean); - if (!keys.length) { - sendChat('Token Home', `/w "${who}" Supply name fragments after --by-name`); - return; - } - - findObjs({ type: 'graphic', pageid: pid }) - .filter(t => matchKey(keys, keyFormat(t.get('name')))) - .forEach(t => { - let home = getHome(t, location || DEFAULT_LOCATION); - if (home) moveToHome(t, home); - }); - break; - } - - case 'summon': { - let anchor = getAnchorFromSelection(msg.selected); - if (!anchor || anchor.pageid !== pid) { - sendChat('Token Home', `/w "${who}" Select exactly one anchor on the current page.`); - return; - } - - findObjs({ type: 'graphic', pageid: pid }).forEach(t => { - let homes = getStoredHomes(t); - let closest = findClosestHome(homes, anchor, location); - if (closest && closest.dist <= radius) { - moveToHome(t, closest.home); - } - }); - break; - } - - case 'clear': { - let tokens = getSelectedTokens(); - if (!tokens.length) { - sendChat( - 'Token Home', - `/w "${who}" Select one or more tokens to clear stored locations.` - ); - return; - } - - tokens.forEach(t => { - if (location) { - clearHome(t, location); - } else { - clearAllHomes(t); - } - }); - break; - } - - default: { - let tokens = getSelectedTokens(); - if (!tokens.length) { - sendChat('Token Home', - `/w "${who}" Usage: !home [--set|--all|--by-name|--summon] [--lN] [--r #]`); - return; - } - tokens.forEach(t => { - let home = getHome(t, location || DEFAULT_LOCATION); - if (home) moveToHome(t, home); + selected.forEach(t => { + const h = getHome(t, loc); + if (!h) return; + + t.set({ + left: clamp(h.left, maxX), + top: clamp(h.top, maxY), + 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); } } From 70b1e8903128da522324542aab0d79308faa35b6 Mon Sep 17 00:00:00 2001 From: keithcurtis1 Date: Mon, 26 Jan 2026 14:16:30 -0800 Subject: [PATCH 11/16] Refactor home management and chat command handling --- TokenHome/1.0.0/TokenHome.js | 153 +++++++++++++++++++---------------- 1 file changed, 85 insertions(+), 68 deletions(-) diff --git a/TokenHome/1.0.0/TokenHome.js b/TokenHome/1.0.0/TokenHome.js index b1a19b5fb..12807b66e 100644 --- a/TokenHome/1.0.0/TokenHome.js +++ b/TokenHome/1.0.0/TokenHome.js @@ -6,39 +6,32 @@ on('ready', () => { const STORAGE_ATTR = 'gmnotes'; const DEFAULT_LOC = 'L1'; const VALID_LAYERS = ['objects', 'map', 'gmlayer', 'walls']; + const DEFAULT_RADIUS = 300; /************************* * REGEX *************************/ - - // Entire hidden storage block const HOME_BLOCK_REGEX = /
\s*TOKENHOME([\s\S]*?)<\/div>/i; - // Individual home lines: L1:123,456,objects const HOME_LINE_REGEX = /^\s*(L\d+)\s*:\s*(-?\d+(?:\.\d*)?)\s*,\s*(-?\d+(?:\.\d*)?)\s*,\s*(\w+)\s*$/gim; /************************* * LOW-LEVEL HELPERS *************************/ - - const extractLocation = (args) => { - const locArg = args.find(a => /^L\d+$/i.test(a)); - return (locArg || DEFAULT_LOC).toUpperCase(); -}; - - 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) => { const notes = readNotes(token); const match = notes.match(HOME_BLOCK_REGEX); @@ -46,7 +39,7 @@ on('ready', () => { if (!match) return homes; -HOME_LINE_REGEX.lastIndex = 0; + HOME_LINE_REGEX.lastIndex = 0; let m; while ((m = HOME_LINE_REGEX.exec(match[1])) !== null) { const [, loc, left, top, layer] = m; @@ -56,20 +49,14 @@ HOME_LINE_REGEX.lastIndex = 0; layer: VALID_LAYERS.includes(layer) ? layer : 'objects' }; } - return homes; }; const saveHomes = (token, homes) => { - let notes = readNotes(token); - - // Strip old block entirely - notes = notes.replace(HOME_BLOCK_REGEX, ''); + let notes = readNotes(token).replace(HOME_BLOCK_REGEX, ''); const lines = Object.entries(homes) - .map(([loc, h]) => - `${loc}:${h.left},${h.top},${h.layer}` - ) + .map(([loc, h]) => `${loc}:${h.left},${h.top},${h.layer}`) .join('\n'); if (!lines.trim()) { @@ -88,7 +75,6 @@ ${lines} const setHome = (token, loc) => { const homes = getHomes(token); - homes[loc] = { left: token.get('left'), top: token.get('top'), @@ -96,95 +82,126 @@ ${lines} ? token.get('layer') : 'objects' }; - saveHomes(token, homes); }; - const getHome = (token, loc) => { - return getHomes(token)[loc]; - }; - /************************* - * PAGE HELPERS + * ANCHOR + SUMMON *************************/ - - const getPageForPlayer = (playerid) => { - if (playerIsGM(playerid)) { - return Campaign().get('playerpageid'); + 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') }; } + if (o._type === 'pin') { + return { left: obj.get('x'), top: obj.get('y') }; + } + return null; + }; - const psp = Campaign().get('playerspecificpages'); - return psp[playerid] || Campaign().get('playerpageid'); + 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 COMMAND + * PAGE *************************/ + const getPageForPlayer = (playerid) => + Campaign().get('playerpageid'); + /************************* + * 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); -let sub = (args[0] || '').trim().toLowerCase(); - -// If the first argument is a location (L#), it is NOT a subcommand -if (/^l\d+$/i.test(sub)) { - sub = ''; -} else { - args.shift(); -} - -const loc = extractLocation(args.concat(sub)); + const rawFlags = msg.content.split(/\s+--/).slice(1).map(f => f.toLowerCase()); + + // Extract location FIRST + let location = null; + rawFlags.forEach(f => { + if (/^l\d+$/.test(f)) location = f.toUpperCase(); + }); + + // Determine mode (location never counts as mode) + let mode = 'recall'; + if (rawFlags.includes('set')) mode = 'set'; + else if (rawFlags.includes('all')) mode = 'all'; + else if (rawFlags.includes('summon')) mode = 'summon'; + + let radius = DEFAULT_RADIUS; + rawFlags.forEach(f => { + if (f.startsWith('radius|')) { + const v = Number(f.split('|')[1]); + if (!isNaN(v)) radius = v; + } + }); const pageid = getPageForPlayer(msg.playerid); const page = getObj('page', pageid); + if (!page) return; const grid = 70 * (page.get('snapping_increment') || 1); const half = grid / 2; const maxX = page.get('width') * grid; const maxY = page.get('height') * grid; - const clamp = (v, max) => Math.max(half, Math.min(v, max - half)); const selected = (msg.selected || []) .map(o => getObj('graphic', o._id)) .filter(Boolean); - switch (sub) { + switch (mode) { - case 'set': { - selected.forEach(t => setHome(t, loc)); + case 'set': + selected.forEach(t => setHome(t, location || DEFAULT_LOC)); break; - } - case 'all': { - findObjs({ type: 'graphic', pageid }) - .forEach(t => { - const h = getHome(t, loc); - if (!h) return; + case 'all': + findObjs({ type: 'graphic', pageid }).forEach(t => { + const h = getHomes(t)[location || DEFAULT_LOC]; + if (!h) return; + t.set({ left: clamp(h.left, maxX), top: clamp(h.top, maxY), layer: h.layer }); + }); + break; + + case 'summon': { + const anchor = getAnchorFromSelection(msg.selected); + if (!anchor) return; + findObjs({ type: 'graphic', pageid }).forEach(t => { + const homes = getHomes(t); + const closest = findClosestHome(homes, anchor, location); + if (closest && closest.dist <= radius) { t.set({ - left: clamp(h.left, maxX), - top: clamp(h.top, maxY), - layer: h.layer + left: clamp(closest.home.left, maxX), + top: clamp(closest.home.top, maxY), + layer: closest.home.layer }); - }); + } + }); break; } - default: { + default: // recall selected.forEach(t => { - const h = getHome(t, loc); + const h = getHomes(t)[location || DEFAULT_LOC]; if (!h) return; - - t.set({ - left: clamp(h.left, maxX), - top: clamp(h.top, maxY), - layer: h.layer - }); + t.set({ left: clamp(h.left, maxX), top: clamp(h.top, maxY), layer: h.layer }); }); - } } }); }); From 2dfc8d737c355127fd5258ff273a8fa016827397 Mon Sep 17 00:00:00 2001 From: keithcurtis1 Date: Mon, 26 Jan 2026 14:16:48 -0800 Subject: [PATCH 12/16] Update TokenHome.js --- TokenHome/TokenHome.js | 153 +++++++++++++++++++++++------------------ 1 file changed, 85 insertions(+), 68 deletions(-) diff --git a/TokenHome/TokenHome.js b/TokenHome/TokenHome.js index b1a19b5fb..12807b66e 100644 --- a/TokenHome/TokenHome.js +++ b/TokenHome/TokenHome.js @@ -6,39 +6,32 @@ on('ready', () => { const STORAGE_ATTR = 'gmnotes'; const DEFAULT_LOC = 'L1'; const VALID_LAYERS = ['objects', 'map', 'gmlayer', 'walls']; + const DEFAULT_RADIUS = 300; /************************* * REGEX *************************/ - - // Entire hidden storage block const HOME_BLOCK_REGEX = /
\s*TOKENHOME([\s\S]*?)<\/div>/i; - // Individual home lines: L1:123,456,objects const HOME_LINE_REGEX = /^\s*(L\d+)\s*:\s*(-?\d+(?:\.\d*)?)\s*,\s*(-?\d+(?:\.\d*)?)\s*,\s*(\w+)\s*$/gim; /************************* * LOW-LEVEL HELPERS *************************/ - - const extractLocation = (args) => { - const locArg = args.find(a => /^L\d+$/i.test(a)); - return (locArg || DEFAULT_LOC).toUpperCase(); -}; - - 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) => { const notes = readNotes(token); const match = notes.match(HOME_BLOCK_REGEX); @@ -46,7 +39,7 @@ on('ready', () => { if (!match) return homes; -HOME_LINE_REGEX.lastIndex = 0; + HOME_LINE_REGEX.lastIndex = 0; let m; while ((m = HOME_LINE_REGEX.exec(match[1])) !== null) { const [, loc, left, top, layer] = m; @@ -56,20 +49,14 @@ HOME_LINE_REGEX.lastIndex = 0; layer: VALID_LAYERS.includes(layer) ? layer : 'objects' }; } - return homes; }; const saveHomes = (token, homes) => { - let notes = readNotes(token); - - // Strip old block entirely - notes = notes.replace(HOME_BLOCK_REGEX, ''); + let notes = readNotes(token).replace(HOME_BLOCK_REGEX, ''); const lines = Object.entries(homes) - .map(([loc, h]) => - `${loc}:${h.left},${h.top},${h.layer}` - ) + .map(([loc, h]) => `${loc}:${h.left},${h.top},${h.layer}`) .join('\n'); if (!lines.trim()) { @@ -88,7 +75,6 @@ ${lines} const setHome = (token, loc) => { const homes = getHomes(token); - homes[loc] = { left: token.get('left'), top: token.get('top'), @@ -96,95 +82,126 @@ ${lines} ? token.get('layer') : 'objects' }; - saveHomes(token, homes); }; - const getHome = (token, loc) => { - return getHomes(token)[loc]; - }; - /************************* - * PAGE HELPERS + * ANCHOR + SUMMON *************************/ - - const getPageForPlayer = (playerid) => { - if (playerIsGM(playerid)) { - return Campaign().get('playerpageid'); + 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') }; } + if (o._type === 'pin') { + return { left: obj.get('x'), top: obj.get('y') }; + } + return null; + }; - const psp = Campaign().get('playerspecificpages'); - return psp[playerid] || Campaign().get('playerpageid'); + 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 COMMAND + * PAGE *************************/ + const getPageForPlayer = (playerid) => + Campaign().get('playerpageid'); + /************************* + * 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); -let sub = (args[0] || '').trim().toLowerCase(); - -// If the first argument is a location (L#), it is NOT a subcommand -if (/^l\d+$/i.test(sub)) { - sub = ''; -} else { - args.shift(); -} - -const loc = extractLocation(args.concat(sub)); + const rawFlags = msg.content.split(/\s+--/).slice(1).map(f => f.toLowerCase()); + + // Extract location FIRST + let location = null; + rawFlags.forEach(f => { + if (/^l\d+$/.test(f)) location = f.toUpperCase(); + }); + + // Determine mode (location never counts as mode) + let mode = 'recall'; + if (rawFlags.includes('set')) mode = 'set'; + else if (rawFlags.includes('all')) mode = 'all'; + else if (rawFlags.includes('summon')) mode = 'summon'; + + let radius = DEFAULT_RADIUS; + rawFlags.forEach(f => { + if (f.startsWith('radius|')) { + const v = Number(f.split('|')[1]); + if (!isNaN(v)) radius = v; + } + }); const pageid = getPageForPlayer(msg.playerid); const page = getObj('page', pageid); + if (!page) return; const grid = 70 * (page.get('snapping_increment') || 1); const half = grid / 2; const maxX = page.get('width') * grid; const maxY = page.get('height') * grid; - const clamp = (v, max) => Math.max(half, Math.min(v, max - half)); const selected = (msg.selected || []) .map(o => getObj('graphic', o._id)) .filter(Boolean); - switch (sub) { + switch (mode) { - case 'set': { - selected.forEach(t => setHome(t, loc)); + case 'set': + selected.forEach(t => setHome(t, location || DEFAULT_LOC)); break; - } - case 'all': { - findObjs({ type: 'graphic', pageid }) - .forEach(t => { - const h = getHome(t, loc); - if (!h) return; + case 'all': + findObjs({ type: 'graphic', pageid }).forEach(t => { + const h = getHomes(t)[location || DEFAULT_LOC]; + if (!h) return; + t.set({ left: clamp(h.left, maxX), top: clamp(h.top, maxY), layer: h.layer }); + }); + break; + + case 'summon': { + const anchor = getAnchorFromSelection(msg.selected); + if (!anchor) return; + findObjs({ type: 'graphic', pageid }).forEach(t => { + const homes = getHomes(t); + const closest = findClosestHome(homes, anchor, location); + if (closest && closest.dist <= radius) { t.set({ - left: clamp(h.left, maxX), - top: clamp(h.top, maxY), - layer: h.layer + left: clamp(closest.home.left, maxX), + top: clamp(closest.home.top, maxY), + layer: closest.home.layer }); - }); + } + }); break; } - default: { + default: // recall selected.forEach(t => { - const h = getHome(t, loc); + const h = getHomes(t)[location || DEFAULT_LOC]; if (!h) return; - - t.set({ - left: clamp(h.left, maxX), - top: clamp(h.top, maxY), - layer: h.layer - }); + t.set({ left: clamp(h.left, maxX), top: clamp(h.top, maxY), layer: h.layer }); }); - } } }); }); From dca084562002f769a9a5c7c3d22f128b0f0fe73a Mon Sep 17 00:00:00 2001 From: keithcurtis1 Date: Tue, 27 Jan 2026 09:14:06 -0800 Subject: [PATCH 13/16] Add files via upload --- TokenHome/1.0.0/TokenHome.js | 499 +++++++++++++++++++++++++++++------ TokenHome/TokenHome.js | 499 +++++++++++++++++++++++++++++------ TokenHome/script.json | 4 +- 3 files changed, 840 insertions(+), 162 deletions(-) diff --git a/TokenHome/1.0.0/TokenHome.js b/TokenHome/1.0.0/TokenHome.js index 12807b66e..0f27ae4bd 100644 --- a/TokenHome/1.0.0/TokenHome.js +++ b/TokenHome/1.0.0/TokenHome.js @@ -1,3 +1,10 @@ +// 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', () => { /************************* @@ -17,6 +24,263 @@ on('ready', () => { 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 *************************/ @@ -33,10 +297,16 @@ on('ready', () => { * STORAGE *************************/ const getHomes = (token) => { - const notes = readNotes(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; @@ -85,8 +355,52 @@ ${lines} 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); + }; + /************************* - * ANCHOR + SUMMON + * 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; @@ -94,11 +408,20 @@ ${lines} 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') }; - } - if (o._type === 'pin') { - return { left: obj.get('x'), top: obj.get('y') }; + 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; }; @@ -108,19 +431,11 @@ ${lines} 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 }; - } + if (!best || d < best.dist) best = { home: h, dist: d }; }); return best; }; - /************************* - * PAGE - *************************/ - const getPageForPlayer = (playerid) => - Campaign().get('playerpageid'); - /************************* * CHAT HANDLER *************************/ @@ -128,80 +443,104 @@ ${lines} if (msg.type !== 'api' || !/^!home\b/i.test(msg.content)) return; if (!playerIsGM(msg.playerid)) return; - const rawFlags = msg.content.split(/\s+--/).slice(1).map(f => f.toLowerCase()); + const args = msg.content.split(/\s+--/).slice(1); + const flags = args.map(a => a.toLowerCase()); - // Extract location FIRST let location = null; - rawFlags.forEach(f => { - if (/^l\d+$/.test(f)) location = f.toUpperCase(); - }); + flags.forEach(f => { if (/^l\d+$/.test(f)) location = f.toUpperCase(); }); - // Determine mode (location never counts as mode) let mode = 'recall'; - if (rawFlags.includes('set')) mode = 'set'; - else if (rawFlags.includes('all')) mode = 'all'; - else if (rawFlags.includes('summon')) mode = 'summon'; + 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; - rawFlags.forEach(f => { + flags.forEach(f => { if (f.startsWith('radius|')) { - const v = Number(f.split('|')[1]); - if (!isNaN(v)) radius = v; + const v = f.split('|')[1]; + if (v.endsWith('g')) { + radius = Number(v.slice(0, -1)) * 70; + } else { + radius = Number(v); + } } }); - const pageid = getPageForPlayer(msg.playerid); - const page = getObj('page', pageid); - if (!page) return; - - const grid = 70 * (page.get('snapping_increment') || 1); - const half = grid / 2; - const maxX = page.get('width') * grid; - const maxY = page.get('height') * grid; - const clamp = (v, max) => Math.max(half, Math.min(v, max - half)); - - const selected = (msg.selected || []) - .map(o => getObj('graphic', o._id)) - .filter(Boolean); - - switch (mode) { - - case 'set': - selected.forEach(t => setHome(t, location || DEFAULT_LOC)); - break; - - case 'all': - findObjs({ type: 'graphic', pageid }).forEach(t => { - const h = getHomes(t)[location || DEFAULT_LOC]; - if (!h) return; - t.set({ left: clamp(h.left, maxX), top: clamp(h.top, maxY), layer: h.layer }); - }); - break; - - case 'summon': { - const anchor = getAnchorFromSelection(msg.selected); - if (!anchor) return; - - findObjs({ type: 'graphic', pageid }).forEach(t => { - const homes = getHomes(t); - const closest = findClosestHome(homes, anchor, location); - if (closest && closest.dist <= radius) { - t.set({ - left: clamp(closest.home.left, maxX), - top: clamp(closest.home.top, maxY), - layer: closest.home.layer - }); - } - }); - break; - } +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); + } - default: // recall - selected.forEach(t => { - const h = getHomes(t)[location || DEFAULT_LOC]; - if (!h) return; - t.set({ left: clamp(h.left, maxX), top: clamp(h.top, maxY), layer: h.layer }); - }); + 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 index 12807b66e..0f27ae4bd 100644 --- a/TokenHome/TokenHome.js +++ b/TokenHome/TokenHome.js @@ -1,3 +1,10 @@ +// 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', () => { /************************* @@ -17,6 +24,263 @@ on('ready', () => { 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 *************************/ @@ -33,10 +297,16 @@ on('ready', () => { * STORAGE *************************/ const getHomes = (token) => { - const notes = readNotes(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; @@ -85,8 +355,52 @@ ${lines} 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); + }; + /************************* - * ANCHOR + SUMMON + * 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; @@ -94,11 +408,20 @@ ${lines} 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') }; - } - if (o._type === 'pin') { - return { left: obj.get('x'), top: obj.get('y') }; + 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; }; @@ -108,19 +431,11 @@ ${lines} 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 }; - } + if (!best || d < best.dist) best = { home: h, dist: d }; }); return best; }; - /************************* - * PAGE - *************************/ - const getPageForPlayer = (playerid) => - Campaign().get('playerpageid'); - /************************* * CHAT HANDLER *************************/ @@ -128,80 +443,104 @@ ${lines} if (msg.type !== 'api' || !/^!home\b/i.test(msg.content)) return; if (!playerIsGM(msg.playerid)) return; - const rawFlags = msg.content.split(/\s+--/).slice(1).map(f => f.toLowerCase()); + const args = msg.content.split(/\s+--/).slice(1); + const flags = args.map(a => a.toLowerCase()); - // Extract location FIRST let location = null; - rawFlags.forEach(f => { - if (/^l\d+$/.test(f)) location = f.toUpperCase(); - }); + flags.forEach(f => { if (/^l\d+$/.test(f)) location = f.toUpperCase(); }); - // Determine mode (location never counts as mode) let mode = 'recall'; - if (rawFlags.includes('set')) mode = 'set'; - else if (rawFlags.includes('all')) mode = 'all'; - else if (rawFlags.includes('summon')) mode = 'summon'; + 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; - rawFlags.forEach(f => { + flags.forEach(f => { if (f.startsWith('radius|')) { - const v = Number(f.split('|')[1]); - if (!isNaN(v)) radius = v; + const v = f.split('|')[1]; + if (v.endsWith('g')) { + radius = Number(v.slice(0, -1)) * 70; + } else { + radius = Number(v); + } } }); - const pageid = getPageForPlayer(msg.playerid); - const page = getObj('page', pageid); - if (!page) return; - - const grid = 70 * (page.get('snapping_increment') || 1); - const half = grid / 2; - const maxX = page.get('width') * grid; - const maxY = page.get('height') * grid; - const clamp = (v, max) => Math.max(half, Math.min(v, max - half)); - - const selected = (msg.selected || []) - .map(o => getObj('graphic', o._id)) - .filter(Boolean); - - switch (mode) { - - case 'set': - selected.forEach(t => setHome(t, location || DEFAULT_LOC)); - break; - - case 'all': - findObjs({ type: 'graphic', pageid }).forEach(t => { - const h = getHomes(t)[location || DEFAULT_LOC]; - if (!h) return; - t.set({ left: clamp(h.left, maxX), top: clamp(h.top, maxY), layer: h.layer }); - }); - break; - - case 'summon': { - const anchor = getAnchorFromSelection(msg.selected); - if (!anchor) return; - - findObjs({ type: 'graphic', pageid }).forEach(t => { - const homes = getHomes(t); - const closest = findClosestHome(homes, anchor, location); - if (closest && closest.dist <= radius) { - t.set({ - left: clamp(closest.home.left, maxX), - top: clamp(closest.home.top, maxY), - layer: closest.home.layer - }); - } - }); - break; - } +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); + } - default: // recall - selected.forEach(t => { - const h = getHomes(t)[location || DEFAULT_LOC]; - if (!h) return; - t.set({ left: clamp(h.left, maxX), top: clamp(h.top, maxY), layer: h.layer }); - }); + 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/script.json b/TokenHome/script.json index b80695f22..7b67a6fa9 100644 --- a/TokenHome/script.json +++ b/TokenHome/script.json @@ -10,5 +10,5 @@ "graphic": "write" }, "conflicts": [], - "previousversions": ["1.0.0"] -} + "previousversions": [""] +} \ No newline at end of file From ca7b65ed070b11c758d419626e411dbcc069aab4 Mon Sep 17 00:00:00 2001 From: keithcurtis1 Date: Tue, 27 Jan 2026 09:16:26 -0800 Subject: [PATCH 14/16] Update previousversions to include version 1.0.0 --- TokenHome/script.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/TokenHome/script.json b/TokenHome/script.json index 7b67a6fa9..b80695f22 100644 --- a/TokenHome/script.json +++ b/TokenHome/script.json @@ -10,5 +10,5 @@ "graphic": "write" }, "conflicts": [], - "previousversions": [""] -} \ No newline at end of file + "previousversions": ["1.0.0"] +} From 7465e1eeb27e3ad63a109a46eaaf531413db35ce Mon Sep 17 00:00:00 2001 From: keithcurtis1 Date: Wed, 28 Jan 2026 11:41:45 -0800 Subject: [PATCH 15/16] Add files via upload --- PinTool/1.0.2/PinTool.js | 1533 ++++++++++++++++++++++++++++++++++++++ PinTool/PinTool.js | 224 +++++- PinTool/readme.md | 5 +- PinTool/script.json | 6 +- 4 files changed, 1728 insertions(+), 40 deletions(-) create mode 100644 PinTool/1.0.2/PinTool.js diff --git a/PinTool/1.0.2/PinTool.js b/PinTool/1.0.2/PinTool.js new file mode 100644 index 000000000..f33aa705f --- /dev/null +++ b/PinTool/1.0.2/PinTool.js @@ -0,0 +1,1533 @@ +// 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. +

+ +
    +
  • Modify pin properties in bulk
  • +
  • Target selected pins, all pins on a page, or explicit pin IDs
  • +
  • Convert map tokens into structured handouts
  • +
  • Place map pins onto the map automatically from a specified handout and header level
  • +
  • Display images directly into chat
  • +
+ +

Base Command: !pintool

+ +

Primary Commands

+ +
    +
  • --set — Modify properties on one or more pins (selected pins, or all pins on a page).
  • +
  • --convert — Convert map tokens into a handout. Can optionally replace existing token pins upon creation.
  • +
  • --place — Places pins on the map based on a specified handout and header level.
  • +
  • --purge — Removes all tokens on the map similar to the selected token, or pins similar to the selected pin.
  • +
  • --help — Open this help handout.
  • +
+ +
+ +

Set Command

+ +

Format:

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

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

+ +

Filter Options

+ +
    +
  • filter|selected — (default) Selected pins
  • +
  • filter|all — All pins on the current page
  • +
  • filter|ID ID ID — Space-separated list of pin IDs
  • +
+ +

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

+
    +
  • x — Horizontal position on page, in pixels
  • +
  • y — Vertical position on page, in pixels
  • +
+ +

Text & Content

+
    +
  • title — Title text displayed on the pin
  • +
  • notes — Notes content associated with the pin
  • +
  • tooltipImage — Roll20 image identifier (URL)
  • +
+ +

Links

+
    +
  • link — ID of the linked handout or object
  • +
  • linkTypehandout or ""
  • +
  • subLink — Header identifier within the handout
  • +
  • subLinkTypeheaderPlayer, headerGM, or ""
  • +
+ +

Visibility

+
    +
  • visibleTo — Overall visibility: all or ""
  • +
  • tooltipVisibleTo — Tooltip visibility
  • +
  • nameplateVisibleTo — Nameplate visibility
  • +
  • imageVisibleTo — Image visibility
  • +
  • notesVisibleTo — Notes visibility
  • +
  • gmNotesVisibleTo — GM Notes visibility
  • +
+ +

Notes Behavior

+
    +
  • + autoNotesType — Controls blockquote-based player visibility: + blockquote or "" +
  • +
+ +

Appearance

+
    +
  • scale — Range: 0.252.0
  • +
+ +

State

+
    +
  • imageDesynced — true / false
  • +
  • notesDesynced — true / false
  • +
  • gmNotesDesynced — true / false
  • +
+ +
+ +

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

+ +
    +
  • + name|h1–h5
    + Header level used for each token’s name. +
  • +
  • + title|string
    + Name of the handout to create or update. May contain spaces. +
  • +
+ +

Optional Arguments

+ +
    +
  • gmnotes|format
  • +
  • tooltip|format
  • +
  • bar1_value|format
  • +
  • bar1_max|format
  • +
  • bar2_value|format
  • +
  • bar2_max|format
  • +
  • bar3_value|format
  • +
  • bar3_max|format
  • +
+ +

Format may be:

+
    +
  • h1–h6
  • +
  • blockquote
  • +
  • code
  • +
  • normal
  • +
+ +

Behavior Flags

+ +
    +
  • + supernotesGMText|true
    + Wraps GM Notes text before a visible separator (-----) in a blockquote. + If no separator exists, the entire section is wrapped. +
  • +
  • + 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

+ +
    +
  • Argument order is preserved and controls output order.
  • +
  • title| values may contain spaces.
  • +
  • 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.
  • +
+ +
+ +

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

+ +
    +
  • + name|h1–h4
    + Header level to scan for in the handout. +
  • +
  • + handout|string
    + Exact, case-sensitive name of an existing handout. Must be unique. +
  • +
+ + + + +

Behavior

+ +
    +
  • Both Notes and GM Notes are scanned.
  • +
  • Notes headers create pins with subLinkType|headerPlayer.
  • +
  • GM Notes headers create pins with subLinkType|headerGM.
  • +
  • Existing pins for matching headers are replaced and retain position.
  • +
  • New pins are placed left-to-right across the top grid row.
  • +
  • Pins use the same default properties as --convert replace|true.
  • +
+ +

Notes

+ +
    +
  • Handout names may contain spaces.
  • +
  • If no matching headers are found, no pins are created.
  • +
  • If more than one handout matches, the command aborts.
  • +
+ +
+ +

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

+ +
    +
  • + tokens or pins
    +
  • +
+
+ +

Example Macros

+ +
    +
  • !pintool --set scale|1
    Sets selected pin to size Medium
  • +
  • !pintool --set scale|1 filter|all
    Sets all pins on page to size Medium
  • +
  • !pintool --set scale|1 filter|-123456789abcd -123456789abce -123456789abcf
    Sets 3 specific pins on page to size Medium
  • +
  • !pintool --set title|Camp notesVisibleTo|all
    Sets title on selected custom pin and makes notes visible to all
  • +
  • !pintool --set autoNotesType|
    changes blockquote behavior on pins.
  • +
  • !pintool --convert name|h2 title|Goblin Notes gmnotes|blockquote
    Good all-purpose conversion command
  • +
+ +
+ +

General Rules

+ +
    +
  • All commands are GM-only.
  • +
  • Read-only attributes (such as _type and _pageid) cannot be modified.
  • +
  • Invalid values abort the entire command.
  • +
+`; + +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", "!pintool --set visibleTo|all") + + `
` + + + `
Blockquote as player text
` + + splitButton("On", "!pintool --set autoNotesType|blockquote") + + splitButton("Off", "!pintool --set autoNotesType|") + + `
` + + + `
Show
` + + splitButton("Text", "!pintool --set imageDesynced|false imageVisibleTo|") + + splitButton("Image", "!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. +
+ + +
+ ` + ); + + 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..f33aa705f 100644 --- a/PinTool/PinTool.js +++ b/PinTool/PinTool.js @@ -8,8 +8,9 @@ API_Meta.PinTool={offset:Number.MAX_SAFE_INTEGER,lineCount:-1}; on("ready", () => { - const version = '1.0.1'; //version number set here + 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 @@ -197,15 +198,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,6 +306,8 @@ The purge command removes all tokens on the map similar to the `; +let sender; + const getPageForPlayer = (playerid) => { let player = getObj('player', playerid); @@ -353,31 +359,150 @@ The purge command removes all tokens on the map similar to the } - 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 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", "!pintool --set visibleTo|all") + + `
    ` + + + `
    Blockquote as player text
    ` + + splitButton("On", "!pintool --set autoNotesType|blockquote") + + splitButton("Off", "!pintool --set autoNotesType|") + + `
    ` + + + `
    Show
    ` + + splitButton("Text", "!pintool --set imageDesynced|false imageVisibleTo|") + + splitButton("Image", "!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) { @@ -623,16 +748,35 @@ The purge command removes all tokens on the map similar to the // ============================================================ 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", - `/direct
    - -
    ` - ); + "PinTool",`
    `, + null, + { noarchive: true }); } // ============================================================ @@ -771,7 +915,7 @@ The purge command removes all tokens on the map similar to the return sendError("Invalid value supplied to --set."); } pins.forEach(p => p.set(updates)); - sendStyledMessage("PinTool — Success", `Updated ${pins.length} pin(s).`); + //sendStyledMessage("PinTool — Success", `Updated ${pins.length} pin(s).`); } // ============================================================ @@ -1362,9 +1506,15 @@ The purge command removes all tokens on the map similar to the 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(); - 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)); @@ -1373,6 +1523,8 @@ The purge command removes all tokens on the map similar to the 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."); }); 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 From e2a1094128574350a399dd5a62de9c4df4bf8699 Mon Sep 17 00:00:00 2001 From: keithcurtis1 Date: Wed, 28 Jan 2026 14:45:07 -0800 Subject: [PATCH 16/16] Cleaned up interface to bring in line with ProdWiz --- PinTool/1.0.2/PinTool.js | 1264 ++++++++++++++++++-------------------- PinTool/PinTool.js | 1264 ++++++++++++++++++-------------------- 2 files changed, 1184 insertions(+), 1344 deletions(-) diff --git a/PinTool/1.0.2/PinTool.js b/PinTool/1.0.2/PinTool.js index f33aa705f..d77a350a5 100644 --- a/PinTool/1.0.2/PinTool.js +++ b/PinTool/1.0.2/PinTool.js @@ -1,29 +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.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 + 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

    @@ -306,326 +305,311 @@ The purge command removes all tokens on the map similar to the `; -let sender; + let sender; - const getPageForPlayer = (playerid) => - { - let player = getObj('player', playerid); - if(playerIsGM(playerid)) - { - return player.get('lastpage') || Campaign().get('playerpageid'); - } + 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]; - } + let psp = Campaign().get('playerspecificpages'); + if (psp[playerid]) { + return psp[playerid]; + } - return Campaign().get('playerpageid'); - }; + return Campaign().get('playerpageid'); + }; - function handleHelp(msg) - { - if(msg.type !== "api") return; + function handleHelp(msg) { + if (msg.type !== "api") return; - let handout = findObjs( - { - _type: "handout", - name: PINTOOL_HELP_NAME - })[0]; + 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() -{ + 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;" + 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) -{ + function splitButton(label, command) { const css = getCSS(); return ( - `${label}` + - `++` + `${label}` + + `++` ); -} + } -function messageButton(label, command) -{ + function messageButton(label, command) { const css = getCSS(); return ( - `${label}` + `${label}` ); -} + } -function showControlPanel() -{ + 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", "!pintool --set visibleTo|all") + - `
    ` + - - `
    Blockquote as player text
    ` + - splitButton("On", "!pintool --set autoNotesType|blockquote") + - splitButton("Off", "!pintool --set autoNotesType|") + - `
    ` + - - `
    Show
    ` + - splitButton("Text", "!pintool --set imageDesynced|false imageVisibleTo|") + - splitButton("Image", "!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") + - `
    ` + - - `
    `; + `
    ` + + + `
    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 + "PinTool Control Panel", + panel ); -} + } - function handlePurge(msg, args) - { - if(!args.length) return; + function handlePurge(msg, args) { + if (!args.length) return; - const mode = args[0]; - if(mode !== "tokens" && mode !== "pins") return; + const mode = args[0]; + if (mode !== "tokens" && mode !== "pins") return; - const confirmed = args.includes("--confirm"); + const confirmed = args.includes("--confirm"); - // -------------------------------- - // CONFIRM PATH (no selection) - // -------------------------------- - if(confirmed) - { - let charId, handoutId, pageId; + // -------------------------------- + // 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); - }); + 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; + if (!pageId) return; - /* ===== PURGE TOKENS (CONFIRM) ===== */ - if(mode === "tokens" && charId) - { - const char = getObj("character", charId); - if(!char) return; + /* ===== PURGE TOKENS (CONFIRM) ===== */ + if (mode === "tokens" && charId) { + const char = getObj("character", charId); + if (!char) return; - const charName = char.get("name") || "Unknown Character"; + const charName = char.get("name") || "Unknown Character"; - const targets = findObjs( - { - _type: "graphic", - _subtype: "token", - _pageid: pageId, - represents: charId - }); + const targets = findObjs( + { + _type: "graphic", + _subtype: "token", + _pageid: pageId, + represents: charId + }); - if(!targets.length) return; + if (!targets.length) return; - targets.forEach(t => t.remove()); + targets.forEach(t => t.remove()); - sendChat( - "PinTool", - `/w gm ✅ Deleted ${targets.length} token(s) for "${_.escape(charName)}".` - ); - } + 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; + /* ===== PURGE PINS (CONFIRM) ===== */ + if (mode === "pins" && handoutId) { + const handout = getObj("handout", handoutId); + if (!handout) return; - const handoutName = handout.get("name") || "Unknown Handout"; + 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(); - } + const targets = findObjs( + { + _type: "pin", + _pageid: pageId + }).filter(p => p.get("link") === handoutId); - return; - } + 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(); + } - // -------------------------------- - // INITIAL PATH (requires selection) - // -------------------------------- - if(!msg.selected || msg.selected.length !== 1) return; + return; + } - const sel = msg.selected[0]; + // -------------------------------- + // INITIAL PATH (requires selection) + // -------------------------------- + if (!msg.selected || msg.selected.length !== 1) return; - /* =============================== - PURGE TOKENS (INITIAL) - =============================== */ - if(mode === "tokens" && sel._type === "graphic") - { - const token = getObj("graphic", sel._id); - if(!token) return; + const sel = msg.selected[0]; - const charId = token.get("represents"); - if(!charId) return; + /* =============================== + PURGE TOKENS (INITIAL) + =============================== */ + if (mode === "tokens" && sel._type === "graphic") { + const token = getObj("graphic", sel._id); + if (!token) return; - const pageId = token.get("_pageid"); - const char = getObj("character", charId); - const charName = char?.get("name") || "Unknown Character"; + const charId = token.get("represents"); + if (!charId) return; - const targets = findObjs( - { - _type: "graphic", - _subtype: "token", - _pageid: pageId, - represents: charId - }); + const pageId = token.get("_pageid"); + const char = getObj("character", charId); + const charName = char?.get("name") || "Unknown Character"; - if(!targets.length) return; + const targets = findObjs( + { + _type: "graphic", + _subtype: "token", + _pageid: pageId, + represents: charId + }); - sendStyledMessage( - "Confirm Purge", - ` + if (!targets.length) return; + + sendStyledMessage( + "Confirm Purge", + `
    This will permanently delete ${targets.length} token(s) @@ -645,37 +629,36 @@ function showControlPanel()
    ` - ); + ); - 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.

    @@ -683,262 +666,239 @@ function showControlPanel() 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."); + 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` + - `
    ` + - `
    `; + `
    ` + + `` + + `
    ` + + `` + + `Send to All` + + `
    ` + + `
    `; sendChat("PinTool", `/w "${sender}" ${imageHtml}`, - null, - { noarchive: true }); - } - + 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 }); - } + 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 = []; + 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 + 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; - }); - } - catch - { - return sendError("Invalid value supplied to --set."); + 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; } - 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; } @@ -947,11 +907,10 @@ function showControlPanel() 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); @@ -959,10 +918,9 @@ function showControlPanel() 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++; } @@ -978,11 +936,11 @@ function showControlPanel() } // ---------------- 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); @@ -992,18 +950,17 @@ function showControlPanel() 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( { @@ -1013,8 +970,7 @@ function showControlPanel() represents: charId }); - if(!tokensOnPage.length) - { + if (!tokensOnPage.length) { sendError("No matching map tokens found."); return; } @@ -1025,30 +981,24 @@ function showControlPanel() 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") @@ -1058,18 +1008,16 @@ function showControlPanel() .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("")}
    `; } @@ -1081,58 +1029,50 @@ function showControlPanel() } - 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; } @@ -1155,7 +1095,7 @@ function showControlPanel() _type: "handout", name: flags.title })[0]; - if(!h) h = createObj("handout", + if (!h) h = createObj("handout", { name: flags.title }); @@ -1165,21 +1105,20 @@ function showControlPanel() 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; } @@ -1193,8 +1132,7 @@ function showControlPanel() })[0]; - if(existingPin) - { + if (existingPin) { existingPin.set( { x: token.get("left"), @@ -1205,8 +1143,7 @@ function showControlPanel() }); } - else - { + else { // Two-step pin creation to avoid desync errors const pin = @@ -1226,8 +1163,7 @@ function showControlPanel() gmNotesDesynced: false }); - if(pin) - { + if (pin) { pin.set( { link: handoutId, @@ -1236,11 +1172,10 @@ function showControlPanel() }); } } - setTimeout(replaceBurndown,0); + setTimeout(replaceBurndown, 0); } else { - if(skipped.length) - { + if (skipped.length) { sendStyledMessage( "Convert: Pins Skipped", `
      ${skipped.map(s => `
    • ${_.escape(s)}
    • `).join("")}
    ` @@ -1256,39 +1191,35 @@ function showControlPanel() 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(); } @@ -1298,23 +1229,21 @@ function showControlPanel() } - // ============================================================ - // 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); @@ -1322,8 +1251,7 @@ function showControlPanel() 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++; } @@ -1332,11 +1260,11 @@ function showControlPanel() 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; @@ -1347,9 +1275,9 @@ function showControlPanel() _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]; @@ -1358,11 +1286,11 @@ function showControlPanel() /* ---------------- 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); @@ -1381,11 +1309,9 @@ function showControlPanel() 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(), @@ -1397,7 +1323,7 @@ function showControlPanel() 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 ---------------- */ @@ -1409,11 +1335,10 @@ function showControlPanel() }); 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; @@ -1421,7 +1346,7 @@ function showControlPanel() /* ---------------- Placement ---------------- */ const burndown = () => { let h = headers.shift(); - if(h) { + if (h) { const headerText = h.text; const subLinkType = h.subLinkType; @@ -1430,8 +1355,7 @@ function showControlPanel() let x, y; const existing = pinByKey[key]; - if(existing) - { + if (existing) { existing.set({ link: handoutId, linkType: "handout", @@ -1445,16 +1369,14 @@ function showControlPanel() }); 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++; } @@ -1478,7 +1400,7 @@ function showControlPanel() }); created++; } - setTimeout(burndown,0); + setTimeout(burndown, 0); } else { /* ---------------- Report ---------------- */ sendStyledMessage( @@ -1499,35 +1421,33 @@ function showControlPanel() - // ============================================================ - // CHAT DISPATCH - // ============================================================ + // ============================================================ + // 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."); - }); + 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);}} +{ 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 f33aa705f..d77a350a5 100644 --- a/PinTool/PinTool.js +++ b/PinTool/PinTool.js @@ -1,29 +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.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 + 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

    @@ -306,326 +305,311 @@ The purge command removes all tokens on the map similar to the `; -let sender; + let sender; - const getPageForPlayer = (playerid) => - { - let player = getObj('player', playerid); - if(playerIsGM(playerid)) - { - return player.get('lastpage') || Campaign().get('playerpageid'); - } + 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]; - } + let psp = Campaign().get('playerspecificpages'); + if (psp[playerid]) { + return psp[playerid]; + } - return Campaign().get('playerpageid'); - }; + return Campaign().get('playerpageid'); + }; - function handleHelp(msg) - { - if(msg.type !== "api") return; + function handleHelp(msg) { + if (msg.type !== "api") return; - let handout = findObjs( - { - _type: "handout", - name: PINTOOL_HELP_NAME - })[0]; + 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() -{ + 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;" + 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) -{ + function splitButton(label, command) { const css = getCSS(); return ( - `${label}` + - `++` + `${label}` + + `++` ); -} + } -function messageButton(label, command) -{ + function messageButton(label, command) { const css = getCSS(); return ( - `${label}` + `${label}` ); -} + } -function showControlPanel() -{ + 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", "!pintool --set visibleTo|all") + - `
    ` + - - `
    Blockquote as player text
    ` + - splitButton("On", "!pintool --set autoNotesType|blockquote") + - splitButton("Off", "!pintool --set autoNotesType|") + - `
    ` + - - `
    Show
    ` + - splitButton("Text", "!pintool --set imageDesynced|false imageVisibleTo|") + - splitButton("Image", "!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") + - `
    ` + - - `
    `; + `
    ` + + + `
    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 + "PinTool Control Panel", + panel ); -} + } - function handlePurge(msg, args) - { - if(!args.length) return; + function handlePurge(msg, args) { + if (!args.length) return; - const mode = args[0]; - if(mode !== "tokens" && mode !== "pins") return; + const mode = args[0]; + if (mode !== "tokens" && mode !== "pins") return; - const confirmed = args.includes("--confirm"); + const confirmed = args.includes("--confirm"); - // -------------------------------- - // CONFIRM PATH (no selection) - // -------------------------------- - if(confirmed) - { - let charId, handoutId, pageId; + // -------------------------------- + // 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); - }); + 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; + if (!pageId) return; - /* ===== PURGE TOKENS (CONFIRM) ===== */ - if(mode === "tokens" && charId) - { - const char = getObj("character", charId); - if(!char) return; + /* ===== PURGE TOKENS (CONFIRM) ===== */ + if (mode === "tokens" && charId) { + const char = getObj("character", charId); + if (!char) return; - const charName = char.get("name") || "Unknown Character"; + const charName = char.get("name") || "Unknown Character"; - const targets = findObjs( - { - _type: "graphic", - _subtype: "token", - _pageid: pageId, - represents: charId - }); + const targets = findObjs( + { + _type: "graphic", + _subtype: "token", + _pageid: pageId, + represents: charId + }); - if(!targets.length) return; + if (!targets.length) return; - targets.forEach(t => t.remove()); + targets.forEach(t => t.remove()); - sendChat( - "PinTool", - `/w gm ✅ Deleted ${targets.length} token(s) for "${_.escape(charName)}".` - ); - } + 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; + /* ===== PURGE PINS (CONFIRM) ===== */ + if (mode === "pins" && handoutId) { + const handout = getObj("handout", handoutId); + if (!handout) return; - const handoutName = handout.get("name") || "Unknown Handout"; + 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(); - } + const targets = findObjs( + { + _type: "pin", + _pageid: pageId + }).filter(p => p.get("link") === handoutId); - return; - } + 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(); + } - // -------------------------------- - // INITIAL PATH (requires selection) - // -------------------------------- - if(!msg.selected || msg.selected.length !== 1) return; + return; + } - const sel = msg.selected[0]; + // -------------------------------- + // INITIAL PATH (requires selection) + // -------------------------------- + if (!msg.selected || msg.selected.length !== 1) return; - /* =============================== - PURGE TOKENS (INITIAL) - =============================== */ - if(mode === "tokens" && sel._type === "graphic") - { - const token = getObj("graphic", sel._id); - if(!token) return; + const sel = msg.selected[0]; - const charId = token.get("represents"); - if(!charId) return; + /* =============================== + PURGE TOKENS (INITIAL) + =============================== */ + if (mode === "tokens" && sel._type === "graphic") { + const token = getObj("graphic", sel._id); + if (!token) return; - const pageId = token.get("_pageid"); - const char = getObj("character", charId); - const charName = char?.get("name") || "Unknown Character"; + const charId = token.get("represents"); + if (!charId) return; - const targets = findObjs( - { - _type: "graphic", - _subtype: "token", - _pageid: pageId, - represents: charId - }); + const pageId = token.get("_pageid"); + const char = getObj("character", charId); + const charName = char?.get("name") || "Unknown Character"; - if(!targets.length) return; + const targets = findObjs( + { + _type: "graphic", + _subtype: "token", + _pageid: pageId, + represents: charId + }); - sendStyledMessage( - "Confirm Purge", - ` + if (!targets.length) return; + + sendStyledMessage( + "Confirm Purge", + `
    This will permanently delete ${targets.length} token(s) @@ -645,37 +629,36 @@ function showControlPanel()
    ` - ); + ); - 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.

    @@ -683,262 +666,239 @@ function showControlPanel() 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."); + 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` + - `
    ` + - `
    `; + `
    ` + + `` + + `
    ` + + `` + + `Send to All` + + `
    ` + + `
    `; sendChat("PinTool", `/w "${sender}" ${imageHtml}`, - null, - { noarchive: true }); - } - + 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 }); - } + 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 = []; + 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 + 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; - }); - } - catch - { - return sendError("Invalid value supplied to --set."); + 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; } - 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; } @@ -947,11 +907,10 @@ function showControlPanel() 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); @@ -959,10 +918,9 @@ function showControlPanel() 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++; } @@ -978,11 +936,11 @@ function showControlPanel() } // ---------------- 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); @@ -992,18 +950,17 @@ function showControlPanel() 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( { @@ -1013,8 +970,7 @@ function showControlPanel() represents: charId }); - if(!tokensOnPage.length) - { + if (!tokensOnPage.length) { sendError("No matching map tokens found."); return; } @@ -1025,30 +981,24 @@ function showControlPanel() 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") @@ -1058,18 +1008,16 @@ function showControlPanel() .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("")}
    `; } @@ -1081,58 +1029,50 @@ function showControlPanel() } - 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; } @@ -1155,7 +1095,7 @@ function showControlPanel() _type: "handout", name: flags.title })[0]; - if(!h) h = createObj("handout", + if (!h) h = createObj("handout", { name: flags.title }); @@ -1165,21 +1105,20 @@ function showControlPanel() 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; } @@ -1193,8 +1132,7 @@ function showControlPanel() })[0]; - if(existingPin) - { + if (existingPin) { existingPin.set( { x: token.get("left"), @@ -1205,8 +1143,7 @@ function showControlPanel() }); } - else - { + else { // Two-step pin creation to avoid desync errors const pin = @@ -1226,8 +1163,7 @@ function showControlPanel() gmNotesDesynced: false }); - if(pin) - { + if (pin) { pin.set( { link: handoutId, @@ -1236,11 +1172,10 @@ function showControlPanel() }); } } - setTimeout(replaceBurndown,0); + setTimeout(replaceBurndown, 0); } else { - if(skipped.length) - { + if (skipped.length) { sendStyledMessage( "Convert: Pins Skipped", `
      ${skipped.map(s => `
    • ${_.escape(s)}
    • `).join("")}
    ` @@ -1256,39 +1191,35 @@ function showControlPanel() 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(); } @@ -1298,23 +1229,21 @@ function showControlPanel() } - // ============================================================ - // 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); @@ -1322,8 +1251,7 @@ function showControlPanel() 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++; } @@ -1332,11 +1260,11 @@ function showControlPanel() 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; @@ -1347,9 +1275,9 @@ function showControlPanel() _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]; @@ -1358,11 +1286,11 @@ function showControlPanel() /* ---------------- 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); @@ -1381,11 +1309,9 @@ function showControlPanel() 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(), @@ -1397,7 +1323,7 @@ function showControlPanel() 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 ---------------- */ @@ -1409,11 +1335,10 @@ function showControlPanel() }); 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; @@ -1421,7 +1346,7 @@ function showControlPanel() /* ---------------- Placement ---------------- */ const burndown = () => { let h = headers.shift(); - if(h) { + if (h) { const headerText = h.text; const subLinkType = h.subLinkType; @@ -1430,8 +1355,7 @@ function showControlPanel() let x, y; const existing = pinByKey[key]; - if(existing) - { + if (existing) { existing.set({ link: handoutId, linkType: "handout", @@ -1445,16 +1369,14 @@ function showControlPanel() }); 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++; } @@ -1478,7 +1400,7 @@ function showControlPanel() }); created++; } - setTimeout(burndown,0); + setTimeout(burndown, 0); } else { /* ---------------- Report ---------------- */ sendStyledMessage( @@ -1499,35 +1421,33 @@ function showControlPanel() - // ============================================================ - // CHAT DISPATCH - // ============================================================ + // ============================================================ + // 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."); - }); + 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);}} +{ try { throw new Error(''); } catch (e) { API_Meta.PinTool.lineCount = (parseInt(e.stack.split(/\n/)[1].replace(/^.*:(\d+):.*$/, '$1'), 10) - API_Meta.PinTool.offset); } }