diff --git a/AutoLinker/1.0.0/AutoLinker.js b/AutoLinker/1.0.0/AutoLinker.js new file mode 100644 index 000000000..e19a3e274 --- /dev/null +++ b/AutoLinker/1.0.0/AutoLinker.js @@ -0,0 +1,216 @@ +// Script: AutoLinker +// By: Keith Curtis and Mik Holmes +// Contact: https://app.roll20.net/users/162065/keithcurtis +var API_Meta = API_Meta||{}; +API_Meta.AutoLinker={offset:Number.MAX_SAFE_INTEGER,lineCount:-1}; +{try{throw new Error('');}catch(e){API_Meta.AutoLinker.offset=(parseInt(e.stack.split(/\n/)[1].replace(/^.*:(\d+):.*$/,'$1'),10)-6);}} + +on("ready", () => { + 'use strict'; + + const version = '1.0.0'; + log('-=> AutoLinker v' + version + ' is loaded. Type "!autolinker --help" for examples.'); + //Changelog + //1.0.0 Debut + + let eventLockout = false; + +const autolink = (str, obj) => { + const regex = /\[(?:([^\]|]*)|([^|]*)\|([^\]|]*))\]/g; + if (!str) str = ""; + + return str.replace(regex, (all, oneWord, link, text) => { + + // ===================================================== + // HEADER LINK WITHOUT PIPE + // [Handout#Header] + // ===================================================== + if (oneWord && oneWord.includes("#")) { + + if (!obj || obj.get("_type") !== "handout") return all; + + const parts = oneWord.split("#"); + const handoutName = parts[0].trim(); + const headerText = parts[1] ? parts[1].trim() : ""; + if (!headerText) return all; + + let targetID = null; + + if (handoutName === "") { + targetID = obj.get("id"); + } else { + const found = findObjs( + { _type: "handout", name: handoutName }, + { caseInsensitive: true } + ); + if (found && found[0]) targetID = found[0].get("id"); + else return all; + } + + const cleanHeader = headerText.replace(/<[^>]*>/g, ""); + const encodedHeader = cleanHeader.replace(/ /g, "%20"); + const url = `http://journal.roll20.net/handout/${targetID}/#${encodedHeader}`; + + // Display text defaults to header text + return `${cleanHeader}`; + } + + // ===================================================== + // SINGLE WORD MODE (namespace links) + // ===================================================== + if (oneWord && oneWord.includes(":")) { + const spell = oneWord.split(":"); + switch (spell[0]) { + case "5e": + return `${spell[1]}`; + case "pf2": + return `${spell[1]}`; + case "gr": + return `${spell[1]}`; + case "r": + return `${spell[1]}`; + case "sot-quote": + return `
${spell[1]}
`; + default: + return all; + } + } + + // ===================================================== + // PIPE MODE + // ===================================================== + if (link && text) { + + // HEADER LINK WITH PIPE + // [Handout#Header|Text] + if (obj && obj.get("_type") === "handout" && link.includes("#")) { + + const parts = link.split("#"); + const handoutName = parts[0].trim(); + const headerText = parts[1] ? parts[1].trim() : ""; + if (!headerText) return all; + + let targetID = null; + + if (handoutName === "") { + targetID = obj.get("id"); + } else { + const found = findObjs( + { _type: "handout", name: handoutName }, + { caseInsensitive: true } + ); + if (found && found[0]) targetID = found[0].get("id"); + else return all; + } + + const cleanHeader = headerText.replace(/<[^>]*>/g, ""); + const encodedHeader = cleanHeader.replace(/ /g, "%20"); + const url = `http://journal.roll20.net/handout/${targetID}/#${encodedHeader}`; + + return `${text}`; + } + + // NAMESPACE LINKS WITH PIPE + if (link.includes(":")) { + const spell = link.split(":"); + switch (spell[0]) { + case "5e": + return `${text}`; + case "pf2": + return `${text}`; + default: + return all; + } + } + + // JOURNAL LINKS + const targetObj = findObjs({ name: link }, { caseInsensitive: true }); + if (targetObj[0]) { + const targetID = targetObj[0].get("id"); + const targetType = targetObj[0].get("type"); + + if (targetType === "handout") + return `${text}`; + else if (targetType === "character") + return `${text}`; + } + } + + return all; + }); +}; + + const runAutolink = (obj, field) => { + if (!eventLockout) { + eventLockout = true; + + obj.get(field, str => { + const newText = autolink(str, obj); + if (newText !== str) obj.set(field, newText); + eventLockout = false; + }); + } + }; + + +/* ============================================================ + * AUTOLINKER HELP + * Triggered by: !autolinker --help + * ============================================================ */ + +const showAutoLinkerHelp = function(playerid) { + + let helpText = + "

Autolinker Help

" + + "

Some examples of the autolinker functionality. These can be used on the notes/gmnotes of any handout or character.

" + + "

Please note that this script works after you save changes to a handout, " + + "but the handout often reloads before the script is finished. Closing and reopening the handout, or clicking Edit again, should give it enough time to properly link things.

" + + "

[goblin|Jimmy] will make a link with the text 'Jimmy' to the 'goblin' handout.

" + + "

[5e:fireball] will link to the 5e compendium page for fireball.

" + + "

[5e:wall of fire|the wall] will make a link with the text 'the wall' to the 5e compendium page for wall of fire

" + + "

Currently 5e: and pf2: will link to their respective compendiums.

" + + "

Handout Header linking:

" + + "

To link to specific headers in a handout (handouts only) use the # character.

" + + "

[Dungeon of Doom#6. Zombie Chorus|See Room 6] will link the header '6. Zombie Chorus' in the handout 'Dungeon of Doom', with the display text 'See Room 6'.

" + + "

If the link goes to a header in the same handout, you do not need to specify the handout:

" + + "

[#6. Zombie Chorus|See Room 6] will link the header '6. Zombie Chorus' in the same handout, with the display text 'See Room 6'.

" + + "

If you do not need the display text of the link to be different from the text of the header, you can omit that part as well:

" + + "

[#6. Zombie Chorus] will link the header '6. Zombie Chorus' in the same handout, with the display text '6. Zombie Chorus'.

"; + + let styledDiv = + "
" + + helpText + + "
"; + + let player = getObj("player", playerid); + if (player) { + sendChat("AutoLinker", "/w \"" + player.get("_displayname") + "\" " + styledDiv); + } +}; + + +/* ============================================================ + * CHAT HANDLER + * ============================================================ */ + +on("chat:message", function(msg) { + if (msg.type !== "api") return; + + if (msg.content.trim() === "!autolinker --help") { + showAutoLinkerHelp(msg.playerid); + } +}); + + + + const registerEventHandlers = () => { + on('change:handout:notes', obj => runAutolink(obj, "notes")); + on('change:handout:gmnotes', obj => runAutolink(obj, "gmnotes")); + on('change:character:bio', obj => runAutolink(obj, "bio")); + on('change:character:gmnotes', obj => runAutolink(obj, "gmnotes")); + }; + + registerEventHandlers(); +}); + +{try{throw new Error('');}catch(e){API_Meta.AutoLinker.lineCount=(parseInt(e.stack.split(/\n/)[1].replace(/^.*:(\d+):.*$/,'$1'),10)-API_Meta.AutoLinker.offset);}} \ No newline at end of file diff --git a/AutoLinker/AutoLinker.js b/AutoLinker/AutoLinker.js new file mode 100644 index 000000000..e19a3e274 --- /dev/null +++ b/AutoLinker/AutoLinker.js @@ -0,0 +1,216 @@ +// Script: AutoLinker +// By: Keith Curtis and Mik Holmes +// Contact: https://app.roll20.net/users/162065/keithcurtis +var API_Meta = API_Meta||{}; +API_Meta.AutoLinker={offset:Number.MAX_SAFE_INTEGER,lineCount:-1}; +{try{throw new Error('');}catch(e){API_Meta.AutoLinker.offset=(parseInt(e.stack.split(/\n/)[1].replace(/^.*:(\d+):.*$/,'$1'),10)-6);}} + +on("ready", () => { + 'use strict'; + + const version = '1.0.0'; + log('-=> AutoLinker v' + version + ' is loaded. Type "!autolinker --help" for examples.'); + //Changelog + //1.0.0 Debut + + let eventLockout = false; + +const autolink = (str, obj) => { + const regex = /\[(?:([^\]|]*)|([^|]*)\|([^\]|]*))\]/g; + if (!str) str = ""; + + return str.replace(regex, (all, oneWord, link, text) => { + + // ===================================================== + // HEADER LINK WITHOUT PIPE + // [Handout#Header] + // ===================================================== + if (oneWord && oneWord.includes("#")) { + + if (!obj || obj.get("_type") !== "handout") return all; + + const parts = oneWord.split("#"); + const handoutName = parts[0].trim(); + const headerText = parts[1] ? parts[1].trim() : ""; + if (!headerText) return all; + + let targetID = null; + + if (handoutName === "") { + targetID = obj.get("id"); + } else { + const found = findObjs( + { _type: "handout", name: handoutName }, + { caseInsensitive: true } + ); + if (found && found[0]) targetID = found[0].get("id"); + else return all; + } + + const cleanHeader = headerText.replace(/<[^>]*>/g, ""); + const encodedHeader = cleanHeader.replace(/ /g, "%20"); + const url = `http://journal.roll20.net/handout/${targetID}/#${encodedHeader}`; + + // Display text defaults to header text + return `${cleanHeader}`; + } + + // ===================================================== + // SINGLE WORD MODE (namespace links) + // ===================================================== + if (oneWord && oneWord.includes(":")) { + const spell = oneWord.split(":"); + switch (spell[0]) { + case "5e": + return `${spell[1]}`; + case "pf2": + return `${spell[1]}`; + case "gr": + return `${spell[1]}`; + case "r": + return `${spell[1]}`; + case "sot-quote": + return `
${spell[1]}
`; + default: + return all; + } + } + + // ===================================================== + // PIPE MODE + // ===================================================== + if (link && text) { + + // HEADER LINK WITH PIPE + // [Handout#Header|Text] + if (obj && obj.get("_type") === "handout" && link.includes("#")) { + + const parts = link.split("#"); + const handoutName = parts[0].trim(); + const headerText = parts[1] ? parts[1].trim() : ""; + if (!headerText) return all; + + let targetID = null; + + if (handoutName === "") { + targetID = obj.get("id"); + } else { + const found = findObjs( + { _type: "handout", name: handoutName }, + { caseInsensitive: true } + ); + if (found && found[0]) targetID = found[0].get("id"); + else return all; + } + + const cleanHeader = headerText.replace(/<[^>]*>/g, ""); + const encodedHeader = cleanHeader.replace(/ /g, "%20"); + const url = `http://journal.roll20.net/handout/${targetID}/#${encodedHeader}`; + + return `${text}`; + } + + // NAMESPACE LINKS WITH PIPE + if (link.includes(":")) { + const spell = link.split(":"); + switch (spell[0]) { + case "5e": + return `${text}`; + case "pf2": + return `${text}`; + default: + return all; + } + } + + // JOURNAL LINKS + const targetObj = findObjs({ name: link }, { caseInsensitive: true }); + if (targetObj[0]) { + const targetID = targetObj[0].get("id"); + const targetType = targetObj[0].get("type"); + + if (targetType === "handout") + return `${text}`; + else if (targetType === "character") + return `${text}`; + } + } + + return all; + }); +}; + + const runAutolink = (obj, field) => { + if (!eventLockout) { + eventLockout = true; + + obj.get(field, str => { + const newText = autolink(str, obj); + if (newText !== str) obj.set(field, newText); + eventLockout = false; + }); + } + }; + + +/* ============================================================ + * AUTOLINKER HELP + * Triggered by: !autolinker --help + * ============================================================ */ + +const showAutoLinkerHelp = function(playerid) { + + let helpText = + "

Autolinker Help

" + + "

Some examples of the autolinker functionality. These can be used on the notes/gmnotes of any handout or character.

" + + "

Please note that this script works after you save changes to a handout, " + + "but the handout often reloads before the script is finished. Closing and reopening the handout, or clicking Edit again, should give it enough time to properly link things.

" + + "

[goblin|Jimmy] will make a link with the text 'Jimmy' to the 'goblin' handout.

" + + "

[5e:fireball] will link to the 5e compendium page for fireball.

" + + "

[5e:wall of fire|the wall] will make a link with the text 'the wall' to the 5e compendium page for wall of fire

" + + "

Currently 5e: and pf2: will link to their respective compendiums.

" + + "

Handout Header linking:

" + + "

To link to specific headers in a handout (handouts only) use the # character.

" + + "

[Dungeon of Doom#6. Zombie Chorus|See Room 6] will link the header '6. Zombie Chorus' in the handout 'Dungeon of Doom', with the display text 'See Room 6'.

" + + "

If the link goes to a header in the same handout, you do not need to specify the handout:

" + + "

[#6. Zombie Chorus|See Room 6] will link the header '6. Zombie Chorus' in the same handout, with the display text 'See Room 6'.

" + + "

If you do not need the display text of the link to be different from the text of the header, you can omit that part as well:

" + + "

[#6. Zombie Chorus] will link the header '6. Zombie Chorus' in the same handout, with the display text '6. Zombie Chorus'.

"; + + let styledDiv = + "
" + + helpText + + "
"; + + let player = getObj("player", playerid); + if (player) { + sendChat("AutoLinker", "/w \"" + player.get("_displayname") + "\" " + styledDiv); + } +}; + + +/* ============================================================ + * CHAT HANDLER + * ============================================================ */ + +on("chat:message", function(msg) { + if (msg.type !== "api") return; + + if (msg.content.trim() === "!autolinker --help") { + showAutoLinkerHelp(msg.playerid); + } +}); + + + + const registerEventHandlers = () => { + on('change:handout:notes', obj => runAutolink(obj, "notes")); + on('change:handout:gmnotes', obj => runAutolink(obj, "gmnotes")); + on('change:character:bio', obj => runAutolink(obj, "bio")); + on('change:character:gmnotes', obj => runAutolink(obj, "gmnotes")); + }; + + registerEventHandlers(); +}); + +{try{throw new Error('');}catch(e){API_Meta.AutoLinker.lineCount=(parseInt(e.stack.split(/\n/)[1].replace(/^.*:(\d+):.*$/,'$1'),10)-API_Meta.AutoLinker.offset);}} \ No newline at end of file diff --git a/AutoLinker/readme.md b/AutoLinker/readme.md new file mode 100644 index 000000000..228f02dfb --- /dev/null +++ b/AutoLinker/readme.md @@ -0,0 +1,80 @@ +# Autolinker + +## Purpose + +Autolinker converts bracketed shorthand written in the **Notes** or **GMNotes** fields of handouts and characters into clickable Roll20 journal or compendium links when the entry is saved. This extends the basic linking functions built into Roll20. + +--- + +## General Usage + +These formats may be used in the Notes or GMNotes fields of any handout or character. + +> Note: The script runs after a save event. Because the handout may refresh before processing finishes, you may need to close and reopen the handout (or click **Edit** again) to see the updated links. + +--- + +## Journal Links + + +[goblin|Jimmy] + + +Creates a link to the handout or character named `goblin`, displayed as **Jimmy**. + +If no display text is provided, standard Roll20 journal linking rules apply. + +--- + +## Compendium Links + + +[5e:fireball] + + +Links to the D&D 5e compendium entry for *fireball*. + + +[5e:wall of fire|the wall] + + +Links to the D&D 5e compendium entry for *wall of fire*, displayed as **the wall**. + +### Supported Compendium Prefixes + +- `5e:` — D&D 5th Edition +- `pf2:` — Pathfinder 2nd Edition + +--- + +## Handout Header Linking + +Header links apply to **handouts only** and use the `#` character. + +### Link to a Header in Another Handout + + +[Dungeon of Doom#6. Zombie Chorus|See Room 6] + + +Links to the header `6. Zombie Chorus` in the handout **Dungeon of Doom**, displayed as **See Room 6**. + +--- + +### Link to a Header in the Same Handout + + +[#6. Zombie Chorus|See Room 6] + + +Links to the header `6. Zombie Chorus` in the current handout, displayed as **See Room 6**. + +--- + +### Omit Display Text + + +[#6. Zombie Chorus] + + +If no display text is supplied, the header text is used as the link text. \ No newline at end of file diff --git a/AutoLinker/script.json b/AutoLinker/script.json new file mode 100644 index 000000000..aea96b8e6 --- /dev/null +++ b/AutoLinker/script.json @@ -0,0 +1,17 @@ +{ + "name": "AutoLinker", + "script": "AutoLinker.js", + "version": "1.0.0", + "description": "# Autolinker\n\n## Purpose\n\nAutolinker converts bracketed shorthand written in the Notes or GMNotes fields of handouts and characters into clickable Roll20 journal or compendium links when the entry is saved. This extends the basic linking functions built into Roll20.\n\n## General Usage\n\nThese formats may be used in the Notes or GMNotes fields of any handout or character.\n\nNote: The script runs after a save event. Because the handout may refresh before processing finishes, you may need to close and reopen the handout (or click Edit again) to see the updated links.\n\n## Journal Links\n\n[goblin|Jimmy]\n\nCreates a link to the handout or character named goblin, displayed as Jimmy.\n\nIf no display text is provided, standard Roll20 journal linking rules apply.\n\n## Compendium Links\n\n[5e:fireball]\n\nLinks to the D&D 5e compendium entry for fireball.\n\n[5e:wall of fire|the wall]\n\nLinks to the D&D 5e compendium entry for wall of fire, displayed as the wall.\n\n### Supported Compendium Prefixes\n\n- 5e: — D&D 5th Edition\n- pf2: — Pathfinder 2nd Edition\n\n## Handout Header Linking\n\nHeader links apply to handouts only and use the # character.\n\n### Link to a Header in Another Handout\n\n[Dungeon of Doom#6. Zombie Chorus|See Room 6]\n\nLinks to the header 6. Zombie Chorus in the handout Dungeon of Doom, displayed as See Room 6.\n\n### Link to a Header in the Same Handout\n\n[#6. Zombie Chorus|See Room 6]\n\nLinks to the header 6. Zombie Chorus in the current handout, displayed as See Room 6.\n\n### Omit Display Text\n\n[#6. Zombie Chorus]\n\nIf no display text is supplied, the header text is used as the link text.", + "authors": "Keith Curtis", + "roll20userid": "162065", + "dependencies": [], + "modifies": { + "handouts": "read,write", + "characters": "read,write" + }, + "conflicts": [], + "previousversions": [ + "1.0.0" + ] +} \ No newline at end of file diff --git a/PInNote/1.0.1/PinNote.js b/PInNote/1.0.1/PinNote.js new file mode 100644 index 000000000..adfb37ca4 --- /dev/null +++ b/PInNote/1.0.1/PinNote.js @@ -0,0 +1,359 @@ +// Script: PinNote +// By: Keith Curtis +// Contact: https://app.roll20.net/users/162065/keithcurtis +var API_Meta = API_Meta||{}; //eslint-disable-line no-var +API_Meta.PinNote={offset:Number.MAX_SAFE_INTEGER,lineCount:-1}; +{try{throw new Error('');}catch(e){API_Meta.PinNote.offset=(parseInt(e.stack.split(/\n/)[1].replace(/^.*:(\d+):.*$/,'$1'),10)-6);}} + + +(() => { + 'use strict'; + + const version = '1.0.1'; //version number set here + log('-=> PinNote v' + version + ' is loaded.'); + //Changelog + //1.0.1 gmtext fix + //1.0.0 Debut + + + const SCRIPT_NAME = 'PinNote'; + + const isGMPlayer = (playerid) => playerIsGM(playerid); + + const getTemplate = (name) => { + if (typeof Supernotes_Templates === 'undefined') { + return null; + } + if (!name) return Supernotes_Templates.generic; + const key = name.toLowerCase(); + return Supernotes_Templates[key] || Supernotes_Templates.generic; + }; + + const sendGenericError = (msg, text) => { + if (typeof Supernotes_Templates === 'undefined') return; + + const t = Supernotes_Templates.generic; + sendChat( + SCRIPT_NAME, + t.boxcode + + t.titlecode + SCRIPT_NAME + + t.textcode + text + + '' + + t.footer + + '' + ); + }; + + +const normalizeHTML = (html) => { + if (!html) return html; + + return html + .replace(/\r\n/g, '') // Windows line endings + .replace(/\n/g, '') // Unix line endings + .replace(/\r/g, ''); // Old Mac line endings +}; + + + /* ============================================================ + * HEADER COLOR ENFORCEMENT + * ============================================================ */ + + const enforceHeaderColor = (html, template) => { + if (!html) return html; + + const colorMatch = template.textcode.match(/color\s*:\s*([^;"]+)/i); + if (!colorMatch) return html; + + const colorValue = colorMatch[1].trim(); + + return html.replace( + /<(h[1-4])\b([^>]*)>/gi, + (match, tag, attrs) => { + + if (/style\s*=/i.test(attrs)) { + return `<${tag}${attrs.replace( + /style\s*=\s*["']([^"']*)["']/i, + (m, styleContent) => + `style="${styleContent}; color: ${colorValue};"` + )}>`; + } + + return `<${tag}${attrs} style="color: ${colorValue};">`; + } + ); + }; + + /* ============================================================ */ + + const parseArgs = (content) => { + const args = {}; + content.replace(/--([^|]+)\|([^\s]+)/gi, (_, k, v) => { + args[k.toLowerCase()] = v.toLowerCase(); + return ''; + }); + return args; + }; + + const extractHandoutSection = ({ handout, subLink, subLinkType }) => { + return new Promise((resolve) => { + + if (!handout) return resolve(null); + + if (!subLink) { + const field = subLinkType === 'headerGM' ? 'gmnotes' : 'notes'; + handout.get(field, (content) => resolve(content || null)); + return; + } + + if (!['headerplayer', 'headergm'].includes(subLinkType?.toLowerCase())) { + return resolve(null); + } + + const field = subLinkType.toLowerCase() === 'headergm' + ? 'gmnotes' + : 'notes'; + + handout.get(field, (content) => { + if (!content) return resolve(null); + + const headerRegex = /<(h[1-4])\b[^>]*>([\s\S]*?)<\/\1>/gi; + let match; + + while ((match = headerRegex.exec(content)) !== null) { + const tagName = match[1]; + const innerHTML = match[2]; + const stripped = innerHTML.replace(/<[^>]+>/g, ''); + + if (stripped === subLink) { + const level = parseInt(tagName[1], 10); + const startIndex = match.index; + + const remainder = content.slice(headerRegex.lastIndex); + + const stopRegex = new RegExp( + `]*>`, + 'i' + ); + + const stopMatch = stopRegex.exec(remainder); + + const endIndex = stopMatch + ? headerRegex.lastIndex + stopMatch.index + : content.length; + + return resolve(content.slice(startIndex, endIndex)); + } + } + + resolve(null); + }); + }); + }; + + const transformBlockquoteMode = (html) => { + + const blockRegex = /]*>([\s\S]*?)<\/blockquote>/gi; + + let match; + let lastIndex = 0; + let playerContent = ''; + let gmContent = ''; + let found = false; + + while ((match = blockRegex.exec(html)) !== null) { + found = true; + gmContent += html.slice(lastIndex, match.index); + playerContent += match[1]; + lastIndex = blockRegex.lastIndex; + } + + gmContent += html.slice(lastIndex); + + if (!found) { + return { player: '', gm: html }; + } + + return { player: playerContent, gm: gmContent }; + }; + + on('chat:message', async (msg) => { + if (msg.type !== 'api' || !msg.content.startsWith('!pinnote')) return; + + if (typeof Supernotes_Templates === 'undefined') { + sendChat(SCRIPT_NAME, `/w gm PinNote requires Supernotes_Templates to be loaded.`); + return; + } + + const args = parseArgs(msg.content); + const isGM = isGMPlayer(msg.playerid); + + if (!msg.selected || msg.selected.length === 0) + return sendGenericError(msg, 'No pin selected.'); + + const sel = msg.selected.find(s => s._type === 'pin'); + if (!sel) + return sendGenericError(msg, 'Selected object is not a pin.'); + + const pin = getObj('pin', sel._id); + if (!pin) + return sendGenericError(msg, 'Selected pin could not be resolved.'); + + const isSynced = + !pin.get('notesDesynced') && + !pin.get('gmNotesDesynced') && + !pin.get('imageDesynced'); + + const linkType = pin.get('linkType'); + + /* ============================================================ + * LINKED HANDOUT MODE + * ============================================================ */ + + if (isSynced && linkType === 'handout') { + + const handoutId = pin.get('link'); + const subLink = pin.get('subLink'); + const subLinkType = pin.get('subLinkType'); + const autoNotesType = pin.get('autoNotesType'); + + const handout = getObj('handout', handoutId); + if (!handout) + return sendGenericError(msg, 'Linked handout not found.'); + + let extracted = await extractHandoutSection({ + handout, + subLink, + subLinkType + }); + + if (!extracted) + return sendGenericError(msg, 'Requested section not found in handout.'); + + const template = getTemplate(args.template); + if (!template) return; + + const sender = pin.get('title') || SCRIPT_NAME; + const titleText = subLink || sender; + + if (subLink) { + const headerStripRegex = /^]*>[\s\S]*?<\/h[1-4]>/i; + extracted = extracted.replace(headerStripRegex, ''); + } + + let to = (args.to || 'pc').toLowerCase(); + if (!isGM) to = 'pc'; + + let whisperPrefix = ''; + const extractingGM = subLinkType?.toLowerCase() === 'headergm'; + + let visibleContent = extracted; + let gmBlock = ''; + + if (autoNotesType === 'blockquote') { + + const transformed = transformBlockquoteMode(extracted); + + visibleContent = enforceHeaderColor(transformed.player, template); + + if (transformed.gm && to !== 'pc') { + gmBlock = + `
` + + enforceHeaderColor(transformed.gm, template) + + `
`; + } + + } else { + visibleContent = enforceHeaderColor(visibleContent, template); + } + + if (extractingGM) { + whisperPrefix = '/w gm '; + } else if (to === 'gm') { + whisperPrefix = '/w gm '; + } else if (to === 'self') { + whisperPrefix = `/w "${msg.who}" `; + } + + const html = + template.boxcode + + template.titlecode + titleText + + template.textcode + + (visibleContent || '') + + gmBlock + + '' + + template.footer + + ''; + + sendChat(sender, whisperPrefix + normalizeHTML(html)); + + return; + } + + /* ============================================================ + * CUSTOM PIN MODE + * ============================================================ */ + + if ( + !pin.get('notesDesynced') && + !pin.get('gmNotesDesynced') && + !pin.get('imageDesynced') + ) { + return sendGenericError( + msg, + 'This pin is not desynced from its linked handout.' + ); + } + + const notes = (pin.get('notes') || '').trim(); + if (!notes) + return sendGenericError(msg, 'This pin has no notes to display.'); + + let to = (args.to || 'pc').toLowerCase(); + if (!isGM) to = 'pc'; + + let whisperPrefix = ''; + if (to === 'gm') whisperPrefix = '/w gm '; + else if (to === 'self') whisperPrefix = `/w "${msg.who}" `; + + const template = getTemplate(args.template); + if (!template) return; + + const sender = pin.get('title') || SCRIPT_NAME; + + let imageBlock = ''; + const tooltipImage = pin.get('tooltipImage'); + if (tooltipImage) { + imageBlock = + ``; + } + + const coloredNotes = enforceHeaderColor(notes, template); + + let gmBlock = ''; + if (isGM && to !== 'pc' && pin.get('gmNotes')) { + gmBlock = + `
` + + enforceHeaderColor(pin.get('gmNotes'), template) + + `
`; + } + + const html = + template.boxcode + + template.titlecode + sender + + template.textcode + + imageBlock + + coloredNotes + + gmBlock + + '' + + template.footer + + ''; + + sendChat(sender, whisperPrefix + normalizeHTML(html)); + + }); + +})(); + +{try{throw new Error('');}catch(e){API_Meta.PinNote.lineCount=(parseInt(e.stack.split(/\n/)[1].replace(/^.*:(\d+):.*$/,'$1'),10)-API_Meta.PinNote.offset);}} diff --git a/PInNote/PinNote.js b/PInNote/PinNote.js index ad8213a89..adfb37ca4 100644 --- a/PInNote/PinNote.js +++ b/PInNote/PinNote.js @@ -9,9 +9,10 @@ API_Meta.PinNote={offset:Number.MAX_SAFE_INTEGER,lineCount:-1}; (() => { 'use strict'; - const version = '1.0.0'; //version number set here + const version = '1.0.1'; //version number set here log('-=> PinNote v' + version + ' is loaded.'); //Changelog + //1.0.1 gmtext fix //1.0.0 Debut @@ -43,6 +44,17 @@ API_Meta.PinNote={offset:Number.MAX_SAFE_INTEGER,lineCount:-1}; ); }; + +const normalizeHTML = (html) => { + if (!html) return html; + + return html + .replace(/\r\n/g, '') // Windows line endings + .replace(/\n/g, '') // Unix line endings + .replace(/\r/g, ''); // Old Mac line endings +}; + + /* ============================================================ * HEADER COLOR ENFORCEMENT * ============================================================ */ @@ -273,7 +285,8 @@ API_Meta.PinNote={offset:Number.MAX_SAFE_INTEGER,lineCount:-1}; template.footer + ''; - sendChat(sender, whisperPrefix + html); + sendChat(sender, whisperPrefix + normalizeHTML(html)); + return; } @@ -337,7 +350,8 @@ API_Meta.PinNote={offset:Number.MAX_SAFE_INTEGER,lineCount:-1}; template.footer + ''; - sendChat(sender, whisperPrefix + html); + sendChat(sender, whisperPrefix + normalizeHTML(html)); + }); })(); diff --git a/PInNote/script.json b/PInNote/script.json index 9c34fa4f3..8f07f9ec0 100644 --- a/PInNote/script.json +++ b/PInNote/script.json @@ -1,7 +1,7 @@ { "name": "PinNote", "script": "PinNote.js", - "version": "1.0.0", + "version": "1.0.1", "description": "# PinNote\n\nPinNote sends information from linked or custom map pins to chat using any Supernotes template. Supernotes must be installed for this script to function.\n\n---\n\n## Arguments\n\nArguments are case-insensitive and use the format:\n\n```\n--key|value\n```\n\n---\n\n### --to|\n\nControls where the message is sent.\n\n#### --to|pc\n\nSends a public message to chat.\n\n- GM notes are never included.\n\n---\n\n#### --to|gm\n\nWhispers the message to the GM only.\n\n- GM notes are included.\n\n---\n\n#### --to|self\n\nWhispers the message to the invoking player.\n\n- GM notes are included only if the invoker is a GM.\n\n---\n\nIf a non-GM runs the command, --to is ignored and treated as pc.\n\n---\n\n### --template| (optional)\n\nSelects a Supernotes display template.\n\n- If omitted or invalid, the generic template is used silently.\n\n---\n\n## Examples\n\n```\n!pinnote\n!pinnote --to|gm\n!pinnote --to|self --template|dark\n!pinnote --template|wizard\n```\n\n---\n\n## Requirements\n\n- Exactly one map pin must be selected.\n - If none are selected, the script reports an error.\n - If multiple are selected, only the first pin is used.\n\n- The pin may be a linked pin or a custom pin.\n - If linked to a handout, the script pulls the relevant section from the handout.\n - If custom, the script uses the Notes field of the pin.\n\n- A custom pin must contain notes.\n - If the Notes field is empty, nothing is sent and an error is shown.\n\n- Supernotes must be installed.\n - If missing, the script exits and notifies the GM.\n\n---\n\nType **!pinnote** in chat to use the script.", "authors": "Keith Curtis", "roll20userid": "162065", @@ -11,5 +11,5 @@ "handout": "read" }, "conflicts": [], - "previousversions": ["1.0.0"] + "previousversions": ["1.0.0", "1.0.1"] } diff --git a/PinTool/1.0.3/PinTool.js b/PinTool/1.0.3/PinTool.js new file mode 100644 index 000000000..f71169c18 --- /dev/null +++ b/PinTool/1.0.3/PinTool.js @@ -0,0 +1,1686 @@ +// 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.3'; //version number set here + log('-=> PinTool v' + version + ' is loaded. Use !pintool --help for documentation.'); + //1.0.3 Normalized headers with html entities, Added more transformation options on --set: math, and words for scale + //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 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") + + `
` + + + `
Scale Pin Placement on Page
Use when you have scaled the page and map and want to scale pin placement across the page to match.
` + + splitButton("Scale Placement", "!pintool --set x|?{Input scale transformation using +-/* number} y|?{Input scale transformation using +-/* number}") + + `
` + + + `
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 + // ============================================================ +const isValidRoll20Image = (url) => { + return typeof url === 'string' && url.includes('files.d20.io/images'); +}; + + +function handleImageToChat(encodedUrl) { + let url = encodedUrl.trim().replace(/^(https?)!!!/i, (_, p) => `${p}://`); + if (!/^https?:\/\//i.test(url)) { + return sendError("Invalid image URL."); + } + + const isRoll20Image = isValidRoll20Image(url); + + let buttons = + `` + + `Send to All`; + + if (isRoll20Image) { + buttons += + ` ` + + `Place image in Pin`; + } + + const imageHtml = + `
` + + `` + + `
${buttons}
` + + `
`; + + 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 SCALE_PRESETS = { + teeny: 0.25, + tiny: 0.5, + small: 0.75, + medium: 1, + large: 1.25, + huge: 1.5, + gigantic: 2 +}; + + + 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."); + +try +{ + const queue = pins.map(p => p.id); + const BATCH_SIZE = 10; + + const processBatch = () => + { + const slice = queue.splice(0, BATCH_SIZE); + + slice.forEach(id => + { + const p = getObj("pin", id); + if(!p) return; + + const updates = {}; + + Object.entries(flags).forEach(([key, raw]) => + { + const spec = PIN_SET_PROPERTIES[key]; + let value = raw; + + // Boolean + if(spec === "boolean") + { + value = raw === "true"; + } + + // Simple numeric (x, y) + else if(spec === "number") + { + const current = Number(p.get(key)); + const opMatch = raw.match(/^([+\-*/])\s*(-?\d*\.?\d+)$/); + + if(opMatch) + { + const op = opMatch[1]; + const operand = Number(opMatch[2]); + if(isNaN(operand)) throw 0; + + if(op === "+") value = current + operand; + else if(op === "-") value = current - operand; + else if(op === "*") value = current * operand; + else if(op === "/") + { + if(operand === 0) throw 0; + value = current / operand; + } + } + else + { + value = Number(raw); + if(isNaN(value)) throw 0; + } + } + + // Enumerated + else if(Array.isArray(spec)) + { + if(!spec.includes(value)) throw 0; + } + + // Bounded numeric (scale) + else if(typeof spec === "object") + { + const current = Number(p.get(key)); + const lower = spec.min; + const upper = spec.max; + + const preset = SCALE_PRESETS[raw.toLowerCase()]; + if(preset !== undefined) + { + value = preset; + } + else + { + const opMatch = raw.match(/^([+\-*/])\s*(-?\d*\.?\d+)$/); + + if(opMatch) + { + const op = opMatch[1]; + const operand = Number(opMatch[2]); + if(isNaN(operand)) throw 0; + + if(op === "+") value = current + operand; + else if(op === "-") value = current - operand; + else if(op === "*") value = current * operand; + else if(op === "/") + { + if(operand === 0) throw 0; + value = current / operand; + } + } + else + { + value = Number(raw); + if(isNaN(value)) throw 0; + } + } + + value = Math.max(lower, Math.min(upper, value)); + } + + updates[key] = value; + }); + + p.set(updates); + p.set({ layer: p.get("layer") }); + + }); + + if(queue.length) + { + setTimeout(processBatch, 0); + } + }; + + processBatch(); +} +catch +{ + return sendError("Invalid value supplied to --set."); +} + + + //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) + { + const raw = m[1]; + +const normalized = m[1] + // Strip inner tags only + .replace(/<[^>]+>/g, "") + // Convert literal   to real NBSP characters + .replace(/ /gi, "\u00A0") + // Decode a few safe entities (do NOT touch whitespace) + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, "\"") + .replace(/'/g, "'") + // Trim only edges, preserve internal spacing + .trim(); + + + headers.push({ + text: normalized, + 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 d77a350a5..f71169c18 100644 --- a/PinTool/PinTool.js +++ b/PinTool/PinTool.js @@ -1,28 +1,30 @@ // 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.3'; //version number set here + log('-=> PinTool v' + version + ' is loaded. Use !pintool --help for documentation.'); + //1.0.3 Normalized headers with html entities, Added more transformation options on --set: math, and words for scale + //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

@@ -305,311 +307,330 @@ 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) { - handout = createObj("handout", + if(!handout) { - name: PINTOOL_HELP_NAME, - archived: false - }); - handout.set("avatar", PINTOOL_HELP_AVATAR); - } + handout = createObj("handout", + { + 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 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") + - `
` + - - `
`; + `
` + + + `
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") + + `
` + + + `
Scale Pin Placement on Page
Use when you have scaled the page and map and want to scale pin placement across the page to match.
` + + splitButton("Scale Placement", "!pintool --set x|?{Input scale transformation using +-/* number} y|?{Input scale transformation using +-/* number}") + + `
` + + + `
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; - - 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; + function handlePurge(msg, args) + { + if(!args.length) return; - args.forEach(a => { - if (a.startsWith("char|")) charId = a.slice(5); - if (a.startsWith("handout|")) handoutId = a.slice(8); - if (a.startsWith("page|")) pageId = a.slice(5); - }); + const mode = args[0]; + if(mode !== "tokens" && mode !== "pins") return; - if (!pageId) return; + const confirmed = args.includes("--confirm"); - /* ===== PURGE TOKENS (CONFIRM) ===== */ - if (mode === "tokens" && charId) { - const char = getObj("character", charId); - if (!char) return; + // -------------------------------- + // CONFIRM PATH (no selection) + // -------------------------------- + if(confirmed) + { + let charId, handoutId, pageId; - const charName = char.get("name") || "Unknown Character"; + args.forEach(a => + { + if(a.startsWith("char|")) charId = a.slice(5); + if(a.startsWith("handout|")) handoutId = a.slice(8); + if(a.startsWith("page|")) pageId = a.slice(5); + }); - const targets = findObjs( - { - _type: "graphic", - _subtype: "token", - _pageid: pageId, - represents: charId - }); + if(!pageId) return; - if (!targets.length) return; + /* ===== PURGE TOKENS (CONFIRM) ===== */ + if(mode === "tokens" && charId) + { + const char = getObj("character", charId); + if(!char) return; - targets.forEach(t => t.remove()); + const charName = char.get("name") || "Unknown Character"; - sendChat( - "PinTool", - `/w gm ✅ Deleted ${targets.length} token(s) for "${_.escape(charName)}".` - ); - } + const targets = findObjs( + { + _type: "graphic", + _subtype: "token", + _pageid: pageId, + represents: charId + }); - /* ===== PURGE PINS (CONFIRM) ===== */ - if (mode === "pins" && handoutId) { - const handout = getObj("handout", handoutId); - if (!handout) return; + if(!targets.length) return; - const handoutName = handout.get("name") || "Unknown Handout"; + targets.forEach(t => t.remove()); - const targets = findObjs( - { - _type: "pin", - _pageid: pageId - }).filter(p => p.get("link") === handoutId); + sendChat( + "PinTool", + `/w gm ✅ Deleted ${targets.length} token(s) for "${_.escape(charName)}".` + ); + } - if (!targets.length) return; + /* ===== PURGE PINS (CONFIRM) ===== */ + if(mode === "pins" && handoutId) + { + const handout = getObj("handout", handoutId); + if(!handout) return; - const count = targets.length; + const handoutName = handout.get("name") || "Unknown Handout"; - 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); + + 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; - } + return; + } - // -------------------------------- - // INITIAL PATH (requires selection) - // -------------------------------- - if (!msg.selected || msg.selected.length !== 1) return; + // -------------------------------- + // INITIAL PATH (requires selection) + // -------------------------------- + if(!msg.selected || msg.selected.length !== 1) return; - const sel = msg.selected[0]; + const sel = msg.selected[0]; - /* =============================== - PURGE TOKENS (INITIAL) - =============================== */ - if (mode === "tokens" && sel._type === "graphic") { - const token = getObj("graphic", sel._id); - if (!token) return; + /* =============================== + 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 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 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 - }); + const targets = findObjs( + { + _type: "graphic", + _subtype: "token", + _pageid: pageId, + represents: charId + }); - if (!targets.length) return; + if(!targets.length) return; - sendStyledMessage( - "Confirm Purge", - ` + sendStyledMessage( + "Confirm Purge", + `
This will permanently delete ${targets.length} token(s) @@ -629,36 +650,37 @@ The purge command removes all tokens on the map similar to the
` - ); + ); - return; - } + return; + } - /* =============================== - PURGE PINS (INITIAL) - =============================== */ - if (mode === "pins" && sel._type === "pin") { - const pin = getObj("pin", sel._id); - if (!pin) return; + /* =============================== + PURGE PINS (INITIAL) + =============================== */ + if(mode === "pins" && sel._type === "pin") + { + const pin = getObj("pin", sel._id); + if(!pin) return; - const handoutId = pin.get("link"); - if (!handoutId) return; + const handoutId = pin.get("link"); + if(!handoutId) return; - const pageId = pin.get("_pageid"); - const handout = getObj("handout", handoutId); - const handoutName = handout?.get("name") || "Unknown Handout"; + const pageId = pin.get("_pageid"); + const handout = getObj("handout", handoutId); + const handoutName = handout?.get("name") || "Unknown Handout"; - const targets = findObjs( - { - _type: "pin", - _pageid: pageId - }).filter(p => p.get("link") === handoutId); + const targets = findObjs( + { + _type: "pin", + _pageid: pageId + }).filter(p => p.get("link") === handoutId); - if (!targets.length) return; + if(!targets.length) return; - sendStyledMessage( - "Confirm Purge", - `

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

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

This cannot be undone.

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

` - ); - return; + ); + return; + } } - } - function normalizeForChat(html) { - return String(html).replace(/\r\n|\r|\n/g, "").trim(); - } + function normalizeForChat(html) + { + return String(html).replace(/\r\n|\r|\n/g, "").trim(); + } - const sendStyledMessage = (titleOrMessage, messageOrUndefined, isPublic = false) => { - const css = getCSS(); - let title, message; + const sendStyledMessage = (titleOrMessage, messageOrUndefined, isPublic = false) => + { + const css = getCSS(); + let title, message; - if (messageOrUndefined === undefined) { - title = scriptName; - message = titleOrMessage; - } - else { - title = titleOrMessage || scriptName; - message = messageOrUndefined; - } + if(messageOrUndefined === undefined) + { + title = scriptName; + message = titleOrMessage; + } + else + { + title = titleOrMessage || scriptName; + message = messageOrUndefined; + } - message = String(message).replace( - /\[([^\]]+)\]\(([^)]+)\)/g, - (_, label, command) => - `${label}` - ); + message = String(message).replace( + /\[([^\]]+)\]\(([^)]+)\)/g, + (_, label, command) => + `${label}` + ); - const html = - `
+ const html = + `
${title}
${message}
`; - sendChat( - scriptName, - `${isPublic ? "" : "/w gm "}${normalizeForChat(html)}`, - null, - { - noarchive: true - } - ); - }; + sendChat( + scriptName, + `${isPublic ? "" : "/w gm "}${normalizeForChat(html)}`, + null, + { + noarchive: true + } + ); + }; - function sendError(msg) { - sendStyledMessage("PinTool — Error", msg); - } + function sendError(msg) + { + sendStyledMessage("PinTool — Error", msg); + } - function sendWarning(msg) { - sendStyledMessage("PinTool — Warning", msg); - } + function sendWarning(msg) + { + sendStyledMessage("PinTool — Warning", msg); + } + + // ============================================================ + // IMAGE → CHAT + // ============================================================ +const isValidRoll20Image = (url) => { + return typeof url === 'string' && url.includes('files.d20.io/images'); +}; - // ============================================================ - // IMAGE → CHAT - // ============================================================ - function handleImageToChat(encodedUrl) { +function handleImageToChat(encodedUrl) { let url = encodedUrl.trim().replace(/^(https?)!!!/i, (_, p) => `${p}://`); - if (!/^https?:\/\//i.test(url)) return sendError("Invalid image URL."); + if (!/^https?:\/\//i.test(url)) { + return sendError("Invalid image URL."); + } - const imageHtml = - `
` + - `` + - `
` + - `` + - `Send to All` + - `
` + - `
`; - - sendChat("PinTool", `/w "${sender}" ${imageHtml}`, - null, - { noarchive: true }); - } + const isRoll20Image = isValidRoll20Image(url); + let buttons = + `` + + `Send to All`; - function handleImageToChatAll(encodedUrl) { - let url = encodedUrl.trim().replace(/^(https?)!!!/i, (_, p) => `${p}://`); - if (!/^https?:\/\//i.test(url)) return sendError("Invalid image URL."); + if (isRoll20Image) { + buttons += + ` ` + + `Place image in Pin`; + } + + const imageHtml = + `
` + + `` + + `
${buttons}
` + + `
`; sendChat( - "PinTool", `
`, - null, - { noarchive: true }); - } + "PinTool", + `/w "${sender}" ${imageHtml}`, + 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: + + function handleImageToChatAll(encodedUrl) { - min: 0.25, - max: 2.0 - }, - imageDesynced: "boolean", - notesDesynced: "boolean", - gmNotesDesynced: "boolean" - }; - - function handleSet(msg, tokens) { - const flags = {}; - let filterRaw = ""; + let url = encodedUrl.trim().replace(/^(https?)!!!/i, (_, p) => `${p}://`); + if(!/^https?:\/\//i.test(url)) return sendError("Invalid image URL."); - for (let i = 0; i < tokens.length; i++) { - const t = tokens[i]; - const idx = t.indexOf("|"); - if (idx === -1) continue; + sendChat( + "PinTool",`
`, + null, + { noarchive: true }); + } - const key = t.slice(0, idx); - let val = t.slice(idx + 1); + // ============================================================ + // SET MODE (pins) + // ============================================================ + +const SCALE_PRESETS = { + teeny: 0.25, + tiny: 0.5, + small: 0.75, + medium: 1, + large: 1.25, + huge: 1.5, + gigantic: 2 +}; + + + 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" + }; - 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; - } + function handleSet(msg, tokens) + { + const flags = {}; + let filterRaw = ""; - if (!PIN_SET_PROPERTIES.hasOwnProperty(key)) - return sendError(`Unknown pin property, or improper capitalization: ${key}`); + for(let i = 0; i < tokens.length; i++) + { + const t = tokens[i]; + const idx = t.indexOf("|"); + if(idx === -1) continue; - const parts = [val]; - let j = i + 1; - while (j < tokens.length && !tokens[j].includes("|")) { - parts.push(tokens[j++]); - } + const key = t.slice(0, idx); + let val = t.slice(idx + 1); - flags[key] = parts.join(" ").trim(); - i = j - 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 (!Object.keys(flags).length) - return sendError("No valid properties supplied to --set."); + 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"); + + + + 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( + 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") { - _type: "pin", - _pageid: pageId + 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."); + +try +{ + const queue = pins.map(p => p.id); + const BATCH_SIZE = 10; + + const processBatch = () => + { + const slice = queue.splice(0, BATCH_SIZE); + + slice.forEach(id => + { + const p = getObj("pin", id); + if(!p) return; + + const updates = {}; + + Object.entries(flags).forEach(([key, raw]) => + { + const spec = PIN_SET_PROPERTIES[key]; + let value = raw; + + // Boolean + if(spec === "boolean") + { + value = raw === "true"; + } + + // Simple numeric (x, y) + else if(spec === "number") + { + const current = Number(p.get(key)); + const opMatch = raw.match(/^([+\-*/])\s*(-?\d*\.?\d+)$/); + + if(opMatch) + { + const op = opMatch[1]; + const operand = Number(opMatch[2]); + if(isNaN(operand)) throw 0; + + if(op === "+") value = current + operand; + else if(op === "-") value = current - operand; + else if(op === "*") value = current * operand; + else if(op === "/") + { + if(operand === 0) throw 0; + value = current / operand; + } + } + else + { + value = Number(raw); + if(isNaN(value)) throw 0; + } + } + + // Enumerated + else if(Array.isArray(spec)) + { + if(!spec.includes(value)) throw 0; + } + + // Bounded numeric (scale) + else if(typeof spec === "object") + { + const current = Number(p.get(key)); + const lower = spec.min; + const upper = spec.max; + + const preset = SCALE_PRESETS[raw.toLowerCase()]; + if(preset !== undefined) + { + value = preset; + } + else + { + const opMatch = raw.match(/^([+\-*/])\s*(-?\d*\.?\d+)$/); + + if(opMatch) + { + const op = opMatch[1]; + const operand = Number(opMatch[2]); + if(isNaN(operand)) throw 0; + + if(op === "+") value = current + operand; + else if(op === "-") value = current - operand; + else if(op === "*") value = current * operand; + else if(op === "/") + { + if(operand === 0) throw 0; + value = current / operand; + } + } + else + { + value = Number(raw); + if(isNaN(value)) throw 0; + } + } + + value = Math.max(lower, Math.min(upper, value)); + } + + updates[key] = value; + }); + + p.set(updates); + p.set({ layer: p.get("layer") }); + }); - } - 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; + if(queue.length) + { + setTimeout(processBatch, 0); } - updates[key] = value; - }); - } - catch { - return sendError("Invalid value supplied to --set."); + }; + + processBatch(); +} +catch +{ + return sendError("Invalid value supplied to --set."); +} + + + //sendStyledMessage("PinTool — Success", `Updated ${pins.length} pin(s).`); } - 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; } @@ -907,10 +1082,11 @@ The purge command removes all tokens on the map similar to the const flags = {}; const orderedSpecs = []; - for (let i = 0; i < tokens.length; i++) { + for(let i = 0; i < tokens.length; i++) + { const t = tokens[i]; const idx = t.indexOf("|"); - if (idx === -1) continue; + if(idx === -1) continue; const key = t.slice(0, idx).toLowerCase(); let val = t.slice(idx + 1); @@ -918,9 +1094,10 @@ The purge command removes all tokens on the map similar to the const parts = [val]; let j = i + 1; - while (j < tokens.length) { + while(j < tokens.length) + { const next = tokens[j]; - if (next.indexOf("|") !== -1) break; + if(next.indexOf("|") !== -1) break; parts.push(next); j++; } @@ -936,11 +1113,11 @@ The purge command removes all tokens on the map similar to the } // ---------------- Required args ---------------- - if (!flags.title) return sendError("--convert requires title|"); - if (!flags.name) return sendError("--convert requires name|h1–h5"); + if(!flags.title) return sendError("--convert requires title|"); + if(!flags.name) return sendError("--convert requires name|h1–h5"); const nameMatch = flags.name.match(/^h([1-5])$/i); - if (!nameMatch) return sendError("name must be h1 through h5"); + if(!nameMatch) return sendError("name must be h1 through h5"); const nameHeaderLevel = parseInt(nameMatch[1], 10); const minAllowedHeader = Math.min(nameHeaderLevel + 1, 6); @@ -950,17 +1127,18 @@ The purge command removes all tokens on the map similar to the const replace = flags.replace === "true"; // NEW // ---------------- Token validation ---------------- - if (!msg.selected || !msg.selected.length) { + if(!msg.selected || !msg.selected.length) + { sendError("Please select a token."); return; } const selectedToken = getObj("graphic", msg.selected[0]._id); - if (!selectedToken) return sendError("Invalid token selection."); + if(!selectedToken) return sendError("Invalid token selection."); const pageId = getPageForPlayer(msg.playerid); const charId = selectedToken.get("represents"); - if (!charId) return sendError("Selected token does not represent a character."); + if(!charId) return sendError("Selected token does not represent a character."); const tokensOnPage = findObjs( { @@ -970,7 +1148,8 @@ The purge command removes all tokens on the map similar to the represents: charId }); - if (!tokensOnPage.length) { + if(!tokensOnPage.length) + { sendError("No matching map tokens found."); return; } @@ -981,24 +1160,30 @@ The purge command removes all tokens on the map similar to the String.fromCharCode(parseInt(m.slice(2), 16)) ); - function decodeNotes(raw) { - if (!raw) return ""; + function decodeNotes(raw) + { + if(!raw) return ""; let s = decodeUnicode(raw); - try { + try + { s = decodeURIComponent(s); } - catch { - try { + catch + { + try + { s = unescape(s); } - catch (e) { - log(e); + catch (e) + { + log(e); } } return s.replace(/^]*>/i, "").replace(/<\/div>$/i, "").trim(); } - function normalizeVisibleText(html) { + function normalizeVisibleText(html) + { return html .replace(//gi, "\n") .replace(/<\/p\s*>/gi, "\n") @@ -1008,16 +1193,18 @@ The purge command removes all tokens on the map similar to the .trim(); } - function applyBlockquoteSplit(html) { + function applyBlockquoteSplit(html) + { const blocks = html.match(//gi); - if (!blocks) return `
${html}
`; + if(!blocks) return `
${html}
`; const idx = blocks.findIndex( b => normalizeVisibleText(b) === "-----" ); // NEW: no separator → everything is player-visible - if (idx === -1) { + if(idx === -1) + { return `
${blocks.join("")}
`; } @@ -1029,50 +1216,58 @@ The purge command removes all tokens on the map similar to the } - function downgradeHeaders(html) { + function downgradeHeaders(html) + { return html .replace(/<\s*h[1-2]\b[^>]*>/gi, "

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

"); } - function encodeProtocol(url) { + function encodeProtocol(url) + { return url.replace(/^(https?):\/\//i, "$1!!!"); } - function convertImages(html) { - if (!html) return html; + function convertImages(html) + { + if(!html) return html; html = html.replace( /\[([^\]]*)\]\((https?:\/\/[^\s)]+)\)/gi, - (m, alt, url) => { - const enc = encodeProtocol(url); - let out = - `${_.escape(alt)}`; - if (imagelinks) { - out += `
[Image]`; - } - return out; + (m, alt, url) => + { + const enc = encodeProtocol(url); + let out = + `${_.escape(alt)}`; + if(imagelinks) + { + out += `
[Image]`; } + 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; } @@ -1095,7 +1290,7 @@ The purge command removes all tokens on the map similar to the _type: "handout", name: flags.title })[0]; - if (!h) h = createObj("handout", + if(!h) h = createObj("handout", { name: flags.title }); @@ -1105,20 +1300,21 @@ The purge command removes all tokens on the map similar to the sendChat("PinTool", `/w gm Handout "${flags.title}" updated.`); - if (!replace) return; + if(!replace) return; const skipped = []; - // const headerRegex = new RegExp(`([\\s\\S]*?)<\\/h${nameHeaderLevel}>`, "gi"); - +// const headerRegex = new RegExp(`([\\s\\S]*?)<\\/h${nameHeaderLevel}>`, "gi"); + const headers = [...pinsToCreateCache]; const replaceBurndown = () => { let header = headers.shift(); - if (header) { + if( header ) { const headerText = _.unescape(header).trim(); const token = tokenByName[headerText]; - if (!token) { + if(!token) + { skipped.push(headerText); return; } @@ -1132,7 +1328,8 @@ The purge command removes all tokens on the map similar to the })[0]; - if (existingPin) { + if(existingPin) + { existingPin.set( { x: token.get("left"), @@ -1143,7 +1340,8 @@ The purge command removes all tokens on the map similar to the }); } - else { + else + { // Two-step pin creation to avoid desync errors const pin = @@ -1163,7 +1361,8 @@ The purge command removes all tokens on the map similar to the gmNotesDesynced: false }); - if (pin) { + if(pin) + { pin.set( { link: handoutId, @@ -1172,10 +1371,11 @@ The purge command removes all tokens on the map similar to the }); } } - setTimeout(replaceBurndown, 0); + setTimeout(replaceBurndown,0); } else { - if (skipped.length) { + if(skipped.length) + { sendStyledMessage( "Convert: Pins Skipped", `
    ${skipped.map(s => `
  • ${_.escape(s)}
  • `).join("")}
` @@ -1191,35 +1391,39 @@ The purge command removes all tokens on the map similar to the replaceBurndown(); }; - const burndown = () => { + const burndown = ()=>{ let token = workTokensOnPage.shift(); - if (token) { + if(token) { const tokenName = token.get("name") || ""; tokenByName[tokenName] = token; // exact string match output.push(`${_.escape(tokenName)}`); pinsToCreateCache.add(_.escape(tokenName)); - orderedSpecs.forEach(spec => { - if (["name", "title", "supernotesgmtext", "imagelinks", "replace"].includes(spec.key)) return; + orderedSpecs.forEach(spec => + { + if(["name", "title", "supernotesgmtext", "imagelinks", "replace"].includes(spec.key)) return; - let value = ""; - if (spec.key === "gmnotes") { - value = decodeNotes(token.get("gmnotes") || ""); - if (supernotes) value = applyBlockquoteSplit(value); - value = downgradeHeaders(value); - value = convertImages(value); - } - else if (spec.key === "tooltip") { - value = token.get("tooltip") || ""; - } - else if (/^bar[1-3]_(value|max)$/.test(spec.key)) { - value = token.get(spec.key) || ""; - } + let value = ""; + if(spec.key === "gmnotes") + { + value = decodeNotes(token.get("gmnotes") || ""); + if(supernotes) value = applyBlockquoteSplit(value); + value = downgradeHeaders(value); + value = convertImages(value); + } + else if(spec.key === "tooltip") + { + value = token.get("tooltip") || ""; + } + else if(/^bar[1-3]_(value|max)$/.test(spec.key)) + { + value = token.get(spec.key) || ""; + } - if (value) output.push(applyFormat(value, spec.val)); - }); - setTimeout(burndown, 0); + if(value) output.push(applyFormat(value, spec.val)); + }); + setTimeout(burndown,0); } else { finishUp(); } @@ -1229,21 +1433,23 @@ The purge command removes all tokens on the map similar to the } - // ============================================================ - // PLACE MODE - // ============================================================ + // ============================================================ + // PLACE MODE + // ============================================================ - function handlePlace(msg, args) { + function handlePlace(msg, args) + { - if (!args.length) return; + if(!args.length) return; /* ---------------- Parse args ---------------- */ const flags = {}; - for (let i = 0; i < args.length; i++) { + for(let i = 0; i < args.length; i++) + { const t = args[i]; const idx = t.indexOf("|"); - if (idx === -1) continue; + if(idx === -1) continue; const key = t.slice(0, idx).toLowerCase(); let val = t.slice(idx + 1); @@ -1251,7 +1457,8 @@ The purge command removes all tokens on the map similar to the const parts = [val]; let j = i + 1; - while (j < args.length && args[j].indexOf("|") === -1) { + while(j < args.length && args[j].indexOf("|") === -1) + { parts.push(args[j]); j++; } @@ -1260,11 +1467,11 @@ The purge command removes all tokens on the map similar to the i = j - 1; } - if (!flags.name) return sendError("--place requires name|h1–h4"); - if (!flags.handout) return sendError("--place requires handout|"); + if(!flags.name) return sendError("--place requires name|h1–h4"); + if(!flags.handout) return sendError("--place requires handout|"); const nameMatch = flags.name.match(/^h([1-4])$/i); - if (!nameMatch) return sendError("name must be h1 through h4"); + if(!nameMatch) return sendError("name must be h1 through h4"); const headerLevel = parseInt(nameMatch[1], 10); const handoutName = flags.handout; @@ -1275,9 +1482,9 @@ The purge command removes all tokens on the map similar to the _type: "handout", name: handoutName }); - if (!handouts.length) + if(!handouts.length) return sendError(`No handout named "${handoutName}" found (case-sensitive).`); - if (handouts.length > 1) + if(handouts.length > 1) return sendError(`More than one handout named "${handoutName}" exists.`); const handout = handouts[0]; @@ -1286,11 +1493,11 @@ The purge command removes all tokens on the map similar to the /* ---------------- Page ---------------- */ const pageId = getPageForPlayer(msg.playerid); - if (typeof pageId === "undefined") + if(typeof pageId === "undefined") return sendError("pageId is not defined."); const page = getObj("page", pageId); - if (!page) return sendError("Invalid pageId."); + if(!page) return sendError("Invalid pageId."); const gridSize = page.get("snapping_increment") * 70 || 70; const maxCols = Math.floor((page.get("width") * 70) / gridSize); @@ -1309,21 +1516,41 @@ The purge command removes all tokens on the map similar to the const headers = []; // { text, subLinkType } - function extractHeaders(html, subLinkType) { - let m; - while ((m = headerRegex.exec(html)) !== null) { - headers.push( - { - text: _.unescape(m[1]).trim(), - subLinkType - }); - } - } +function extractHeaders(html, subLinkType) +{ + let m; + while((m = headerRegex.exec(html)) !== null) + { + const raw = m[1]; + +const normalized = m[1] + // Strip inner tags only + .replace(/<[^>]+>/g, "") + // Convert literal   to real NBSP characters + .replace(/ /gi, "\u00A0") + // Decode a few safe entities (do NOT touch whitespace) + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, "\"") + .replace(/'/g, "'") + // Trim only edges, preserve internal spacing + .trim(); + + + headers.push({ + text: normalized, + subLinkType + }); + } +} + + 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 ---------------- */ @@ -1335,10 +1562,11 @@ The purge command removes all tokens on the map similar to the }); const pinByKey = {}; - existingPins.forEach(p => { - const key = `${p.get("subLink")}||${p.get("subLinkType") || ""}`; - pinByKey[key] = p; - }); + existingPins.forEach(p => + { + const key = `${p.get("subLink")}||${p.get("subLinkType") || ""}`; + pinByKey[key] = p; + }); let created = 0; let replaced = 0; @@ -1346,7 +1574,7 @@ The purge command removes all tokens on the map similar to the /* ---------------- Placement ---------------- */ const burndown = () => { let h = headers.shift(); - if (h) { + if(h) { const headerText = h.text; const subLinkType = h.subLinkType; @@ -1355,7 +1583,8 @@ The purge command removes all tokens on the map similar to the let x, y; const existing = pinByKey[key]; - if (existing) { + if(existing) + { existing.set({ link: handoutId, linkType: "handout", @@ -1369,14 +1598,16 @@ The purge command removes all tokens on the map similar to the }); replaced++; } - else { + else + { x = startX + col * gridSize; // Stagger every other pin in the row by 20px vertically y = startY + row * gridSize + (col % 2 ? 20 : 0); col++; - if (col >= maxCols) { + if(col >= maxCols) + { col = 0; row++; } @@ -1400,7 +1631,7 @@ The purge command removes all tokens on the map similar to the }); created++; } - setTimeout(burndown, 0); + setTimeout(burndown,0); } else { /* ---------------- Report ---------------- */ sendStyledMessage( @@ -1421,33 +1652,35 @@ The purge command removes all tokens on the map similar to the - // ============================================================ - // CHAT DISPATCH - // ============================================================ - - on("chat:message", msg => { - if (msg.type !== "api" || !/^!pintool\b/i.test(msg.content)) return; - sender = msg.who.replace(/\s\(GM\)$/, ''); - const parts = msg.content.trim().split(/\s+/); - const cmd = parts[1]?.toLowerCase(); - - if (parts.length === 1) { - showControlPanel(); - return; - } + // ============================================================ + // CHAT DISPATCH + // ============================================================ - 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/script.json b/PinTool/script.json index 88614b3e4..fc66979ec 100644 --- a/PinTool/script.json +++ b/PinTool/script.json @@ -1,7 +1,7 @@ { "name": "PinTool", "script": "PinTool.js", - "version": "1.0.2", + "version": "1.0.3", "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", @@ -11,5 +11,5 @@ "pin": "write" }, "conflicts": [], - "previousversions": ["1.0.0","1.0.1"] -} \ No newline at end of file + "previousversions": ["1.0.0","1.0.1","1.0.2"] +}