From e2814c68cd26de4a880d830150c715c03a9ba6be Mon Sep 17 00:00:00 2001 From: keithcurtis1 Date: Tue, 17 Feb 2026 12:16:40 -0800 Subject: [PATCH 1/9] Bump version to 1.0.1 and fix gmtext Updated version number to 1.0.1 and added gmtext fix. --- PInNote/PinNote.js | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) 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)); + }); })(); From b202e6f28e50d671fce6a96dbcb56df905d583f2 Mon Sep 17 00:00:00 2001 From: keithcurtis1 Date: Tue, 17 Feb 2026 12:17:15 -0800 Subject: [PATCH 2/9] Create PinNote.js --- PInNote/1.0.1/PinNote.js | 359 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 359 insertions(+) create mode 100644 PInNote/1.0.1/PinNote.js 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);}} From 28de5cc3cc1464aba8db726ae2b2309967ac1859 Mon Sep 17 00:00:00 2001 From: keithcurtis1 Date: Tue, 17 Feb 2026 12:17:53 -0800 Subject: [PATCH 3/9] Update version to 1.0.1 and add previous version --- PInNote/script.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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"] } From 010821c742ba6781242280f6d937e3226176872a Mon Sep 17 00:00:00 2001 From: keithcurtis1 Date: Sat, 28 Feb 2026 12:54:14 -0800 Subject: [PATCH 4/9] Add files via upload --- AutoLinker/1.0.0/AutoLinker.js | 216 +++++++++++++++++++++++++++++++++ AutoLinker/AutoLinker.js | 216 +++++++++++++++++++++++++++++++++ AutoLinker/readme.md | 80 ++++++++++++ AutoLinker/script.json | 17 +++ 4 files changed, 529 insertions(+) create mode 100644 AutoLinker/1.0.0/AutoLinker.js create mode 100644 AutoLinker/AutoLinker.js create mode 100644 AutoLinker/readme.md create mode 100644 AutoLinker/script.json 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 From 791955552685a2befaf9553740166e432673ba86 Mon Sep 17 00:00:00 2001 From: keithcurtis1 Date: Mon, 2 Mar 2026 13:17:59 -0800 Subject: [PATCH 5/9] Add files via upload --- PinTool/1.0.3/PinTool.js | 2539 ++++++++++++++++++++++++++++++++ PinTool/PinTool.js | 2992 ++++++++++++++++++++++++++------------ PinTool/readme.md | 41 +- PinTool/script.json | 6 +- 4 files changed, 4603 insertions(+), 975 deletions(-) create mode 100644 PinTool/1.0.3/PinTool.js diff --git a/PinTool/1.0.3/PinTool.js b/PinTool/1.0.3/PinTool.js new file mode 100644 index 000000000..7d521588e --- /dev/null +++ b/PinTool/1.0.3/PinTool.js @@ -0,0 +1,2539 @@ +// 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 Huge update: Normalized headers with html entities, Added more transformation options on --set: math, and words for scale, Added advanced customization, pin style library, auto numbering + //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 ICON_SPRITE_URL = "https://files.d20.io/images/477999554/bETqvktx8A9TszRZBnmDWg/original.png?1772436951"; + const ICON_SIZE = 40; // original sprite slice size + const ICON_DISPLAY_SIZE = 20; // rendered size (50%) + + 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.
  • +
  • --library — Browse and copy saved pin styles from the Pin Library page.
  • +
  • --transform — Apply transformations to pins (currently supports automatic text icon generation).
  • +
+ +
+ +

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
  • +
  • Preset sizes: teeny, tiny, small, medium, large, huge, gigantic
  • +
  • bgColor — Background color (hex rgb or rgba for transparency) or transparent)
  • +
  • shapeteardrop, circle, diamond, square
  • +
  • tooltipImageSizesmall, medium, large, xl
  • +
  • Display Mode
  • +
  • customizationTypeicon or image
  • +
  • icon — Icon preset identifier
  • +
  • pinImage — Roll20 image URL for custom pin image
  • +
  • useTextIcontrue or false
  • +
  • iconText — Up to 3 characters displayed as a text icon
  • +

    Note, setting icon, iconText, or pinImage will automatically change the customizationType to match.

    + +
+ +

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
    +
  • +
+ +
+ +

Transform Command

+ +

+The transform command applies derived transformations to pins. +

+ +

Format:

+
+!pintool --transform autotext [filter|target]
+
+ +

Supported Transforms

+ +
    +
  • + autotext
    + Derives up to 3 characters from the pin’s title (or subLink if title is empty) + and converts the pin into a text icon. +
  • +
+ +

+Text is derived from the first alphanumeric characters in the title. +If no valid characters are found, the pin is not modified. +

+ +
+ +

Pin Library

+ +

+The library command allows you to browse and copy saved pin styles +from a dedicated page named Pin Library. +

+ +

Format:

+
+!pintool --library
+!pintool --library keyword|keyword
+
+ +

Setup

+ +
    +
  • Create a page named exactly Pin Library.
  • +
  • Create pins on that page configured with the styles you want to reuse.
  • +
  • Add keywords to each pin title in square brackets:
  • +
+ +
+Camp [travel, wilderness]
+Battle [combat, viking]
+Treasure [loot]
+
+ +

Behavior

+ +
    +
  • !pintool --library lists all available keywords.
  • +
  • Selecting a keyword displays matching pin styles.
  • +
  • Clicking a style copies its appearance to selected pins.
  • +
  • Position, title, notes, and links are not overwritten.
  • +
+ +

+If the Pin Library page does not exist or contains no valid keyworded pins, +the command will display an error. +

+ +
+ +

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
  • +
  • !pintool --set bgColor|#307bb8 shape|circle
    Sets selected pin color and shape
  • +
  • !pintool --set pinImage|https://...
    Sets custom pin image
  • +
  • !pintool --transform autotext
    Generates 3-letter text icons from titles
  • +
  • !pintool --library
    Browse saved pin styles
  • +
+ +
+ +

General Rules

+ +
    +
  • All commands are GM-only.
  • +
  • Read-only attributes (such as _type and _pageid) cannot be modified.
  • +
  • Invalid values abort the entire command.
  • +
+`; + + const ICON_ORDER = [ + "base-dot", + "base-castle", + "base-skullSimple", + "base-spartanHelm", + "base-radioactive", + "base-heart", + "base-star", + "base-starSign", + "base-pin", + "base-speechBubble", + "base-file", + "base-plus", + "base-circleCross", + "base-dartBoard", + "base-badge", + "base-flagPin", + "base-crosshair", + "base-scrollOpen", + "base-diamond", + "base-photo", + "base-fourStarShort", + "base-circleStar", + "base-lock", + "base-crown", + "base-leaf", + "base-signpost", + "base-beer", + "base-compass", + "base-video", + "base-key", + "base-chest", + "base-village", + "base-swordUp", + "base-house", + "base-house2", + "base-church", + "base-government", + "base-blacksmith", + "base-stable", + "base-gear", + "base-bridge", + "base-mountain", + "base-exclamation", + "base-question" + ]; + + + + + 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;", + + iconSpriteButton: "display:inline-block;" + + "width:40px;" + + "height:40px;" + + "background-color:#000;" + // force black behind transparent png + "background-repeat:no-repeat;" + + "background-size:1760px 40px;" + + "border:1px solid #555;" + + "border-radius:2px;" + + "margin:1px;" + + "padding:0;" + + "line-height:0;" + + "font-size:0;" + + "text-decoration:none;" + + "vertical-align:top;", + + panelButtonLeft: "display:inline-block;" + + "padding:2px 6px;" + + "border-radius:6px;" + + "background:#333;" + + "border:1px solid #555;" + + "border-right:none;" + + "color:#9fd3ff;" + + "text-decoration:none;" + + "font-size:12px;" + + "margin:0 2px 4px 0px;", + + 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;", + + colorButton: "display:inline-block;" + + "width:20px;" + + "height:20px;" + + "border:1px solid #555;" + + "border-radius:2px;" + + "margin:1px;" + + "padding:0;" + + "vertical-align:middle;" + + "text-decoration:none;", + + libraryPinButton: "display:block;" + + "margin:4px 0;" + + "padding:4px;" + + "border-radius:4px;" + + "background:#2a2a2a;" + + "border:1px solid #555;" + + "color:#fff;" + + "text-decoration:none;" + + "font-size:12px;" + + "white-space:nowrap;", + + libraryPinVisual: "display:inline-block;" + + "width:35px;" + + "height:35px;" + + "margin-right:6px;" + + "vertical-align:middle;" + + "border:1px solid #555;" + + "border-radius:4px;" + + "background-color:#000;", + + libraryPinText: "display:inline-block;" + + "vertical-align:middle;" + }; + } + + function splitButton(label, command) + { + const css = getCSS(); + + return ( + `${label}` // + + //`++` + ); + } + + function iconSpriteButton(index, iconValue) + { + const offsetX = -(index * ICON_DISPLAY_SIZE); + + return ` +
+ + +
+ `; + } + + function messageButton(label, command) + { + const css = getCSS(); + + return ( + `${label}` + ); + } + + function showControlPanel() + { + const css = getCSS(); + + const colors = [ + "#242424", "#307bb8", "#721211", "#e59a00", "#b40f69", "#2d0075", "#e26608", "#588b02", "#bb1804", + "#ffffff", "#000000" + ]; + + const colorButtons = colors.map((c, i) => + (i === colors.length - 2 ? "
" : "") + + `` + ).join(''); + + const panel = + `
` + + + // SIZE + `
Size
` + + splitButton("Teeny", "!pintool --set scale|teeny") + + splitButton("Tiny", "!pintool --set scale|tiny") + + splitButton("Small", "!pintool --set scale|small") + + splitButton("Medium", "!pintool --set scale|medium") + + `
` + + splitButton("Large", "!pintool --set scale|large") + + splitButton("Huge", "!pintool --set scale|huge") + + splitButton("Gigantic", "!pintool --set scale|gigantic") + + `
` + + + // VISIBILITY + `
Visible
` + + splitButton("GM Only", "!pintool --set visibleTo|") + + splitButton("All Players", "!pintool --set visibleTo|all") + + `
` + + + // BLOCKQUOTE + `
Blockquote as player text
` + + splitButton("On", "!pintool --set autoNotesType|blockquote") + + splitButton("Off", "!pintool --set autoNotesType|") + + `
` + + + // DISPLAY SYNC + `
Display
` + + splitButton("From Handout", "!pintool --set imageDesynced|false imageVisibleTo|") + + splitButton("Custom", "!pintool --set imageDesynced|true imageVisibleTo|all") + + `
` + + + // CUSTOMIZATION MODE + `
Customization Mode
` + + splitButton("Icon", "!pintool --set customizationType|icon") + + splitButton("Image", "!pintool --set customizationType|image") + + splitButton("Text", "!pintool --set useTextIcon|true") + + splitButton("Set Text", "!pintool --set iconText|?{Input up to 3 characters}") + `
` + + splitButton("Pin Text from Title", "!pintool --transform autotext") + + splitButton("Hide Names", "!pintool --set nameplateVisibleTo|") + + + + `
` + + + + // ICON QUICK PICKS + `
Icon Presets
` + + ICON_ORDER.map((icon, i) => iconSpriteButton(i, icon)).join("") + + `
` + + + // PIN IMAGE + `
Pin Image
` + + splitButton("Set Pin Image", "!pintool --set pinImage|?{Roll20 Image URL}") + + splitButton("Clear Image", "!pintool --set pinImage| customizationType|icon") + + `
` + + + // TOOLTIP IMAGE + `
Tooltip Image
` + + splitButton("Set Tooltip Image", "!pintool --set tooltipImage|?{Roll20 Image URL}") + + splitButton("S", "!pintool --set tooltipImageSize|small") + + splitButton("M", "!pintool --set tooltipImageSize|medium") + + splitButton("L", "!pintool --set tooltipImageSize|large") + + splitButton("XL", "!pintool --set tooltipImageSize|xl") + + `
` + + + // SHAPE + `
Shape
` + + splitButton("Teardrop", "!pintool --set shape|teardrop") + + splitButton("Circle", "!pintool --set shape|circle") + + splitButton("Diamond", "!pintool --set shape|diamond") + + splitButton("Square", "!pintool --set shape|square") + + `
` + + + // BACKGROUND COLOR + `
Pin Colors
` + + colorButtons + + splitButton("Transparent", "!pintool --set bgColor|") + + splitButton("Custom Color", "!pintool --set bgColor|?{Enter custom color (hex or transparent)}") + + `
` + + + // Pin LIbrary + `
Pin Library ` + + splitButton("See Styles", "!pintool --library") + + `
` + + + // SCALE PLACEMENT + `
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 FROM HANDOUT + `
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}") + + `
` + + + `
`; + + 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); + } + + +//Pin library functions + + function parseLibraryTitle(title) + { + const match = title.match(/\[(.*?)\]/); + if(!match) return null; + + const keywordBlock = match[1]; + const keywords = keywordBlock + .split(',') + .map(k => k.trim().toLowerCase()) + .filter(k => k.length); + + const cleanTitle = title.replace(/\s*\[.*?\]\s*/, '').trim(); + + return { + cleanTitle, + keywords + }; + } + + function getLibraryPage() + { + return findObjs( + { + _type: "page", + name: "Pin Library" + })[0]; + } + + +function showLibraryKeywords() +{ + const css = getCSS(); + const page = getLibraryPage(); + + if(!page) { + sendError("Pin Library page not found. Create a page named 'Pin Library' and add pins with keywords. See !pintool --help for details."); + return; + } + + const pins = findObjs( + { + _type: "pin", + _pageid: page.id + }); + + const keywordSet = new Set(); + + pins.forEach(pin => + { + const parsed = parseLibraryTitle(pin.get("title")); + if(!parsed) return; + + parsed.keywords.forEach(k => keywordSet.add(k)); + }); + + const keywords = Array.from(keywordSet).sort(); + + if(keywords.length === 0) { + sendError("No pins with keywords found on the Pin Library page. See !pintool --help to create them."); + return; + } + + const buttons = keywords.map(k => + `${k}` + ).join("
"); + + const output = + `
+
Pin Library
+
+ ${buttons} +
+
+${messageButton("Main Menu", "!pintool")} +
+
`; + + sendChat("PinTool", `/w gm ${output}`); +} + + +function buildLibraryPinButton(pin) { + const css = getCSS(); + const title = pin.get("title"); + const parsed = parseLibraryTitle(title); + if (!parsed) return ""; + + const cleanTitle = parsed.cleanTitle; + + const useTextIcon = pin.get("useTextIcon"); + const customizationType = pin.get("customizationType"); + const pinImage = pin.get("pinImage"); + const icon = pin.get("icon"); + const bgColor = pin.get("bgColor") || "#000"; + const iconText = pin.get("iconText"); + + let visual = ""; + + // Base styles for the visual div + const baseStyle = ` + width:35px; + height:35px; + display:inline-block; + vertical-align:middle; + border-radius:4px; + text-align:center; + line-height:35px; + font-weight:bold; + overflow:hidden; + background-size: auto 100%; + `; + + if (useTextIcon === true && iconText) { + // Text Icon + visual = `
${iconText.substring(0,3)}
`; + } + else if (customizationType === "image" && pinImage) { + // Image pin — always light neutral gray behind + const grayBg = "#ccc"; + visual = `
+
`; + } +else if (customizationType === "icon" && icon) { + const iconIndex = ICON_ORDER.indexOf(icon); + const totalIcons = ICON_ORDER.length; + const bgPosPercent = (iconIndex / (totalIcons - 1)) * 100; + + visual = `
+
`; +} + else { + // Only color + visual = `
`; + } + + return ` + ${visual} + ${cleanTitle} + `; +} + + + function showLibraryKeywordResults(keyword) + { + const css = getCSS(); + const page = getLibraryPage(); + if(!page) return; + + const lower = keyword.toLowerCase(); + + const pins = findObjs( + { + _type: "pin", + _pageid: page.id + }); + + const matches = pins.filter(pin => + { + const parsed = parseLibraryTitle(pin.get("title")); + if(!parsed) return false; + return parsed.keywords.includes(lower); + }); + + matches.sort((a, b) => + { + const pa = parseLibraryTitle(a.get("title")); + const pb = parseLibraryTitle(b.get("title")); + return pa.cleanTitle.localeCompare(pb.cleanTitle); + }); + + const buttons = matches.map(buildLibraryPinButton).join(""); + + const output = + `
+
Keyword: ${keyword}
+
+ ${buttons} +
+
+${splitButton("Change Keyword", "!pintool --library")} + ${splitButton("Main Menu", "!pintool")} +
+
`; + + sendChat("PinTool", `/w gm ${output}`); + } + + + function copyLibraryPinToSelection(pinId, selected) + { + const libraryPin = getObj("pin", pinId); + if(!libraryPin) return; + + const targets = (selected || []) + .map(s => getObj(s._type, s._id)) + .filter(o => o && o.get("_type") === "pin"); + + if(!targets.length) + { + sendChat("PinTool", `/w gm No pins selected.`); + return; + } + + const props = libraryPin.attributes; + + targets.forEach(target => + { + Object.keys(props).forEach(key => + { + if([ + "title", + "link", + "linkType", + "subLink", + "subLinkType", + "_id", + "_type", + "x", + "y", + "notes", + "gmNotes", + "y", + "y", + "_pageid" + ].includes(key)) return; + + target.set(key, props[key]); + }); + }); + } + + + + + + + + + + // ============================================================ + // 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", + + tooltipImage: "roll20image", + pinImage: "roll20image", + + 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", + + bgColor: "color", + shape: ["teardrop", "circle", "diamond", "square"], + + customizationType: ["icon", "image"], + icon: [ + "base-dot", "base-castle", "base-skullSimple", "base-spartanHelm", + "base-radioactive", "base-heart", "base-star", "base-starSign", + "base-pin", "base-speechBubble", "base-file", "base-plus", + "base-circleCross", "base-dartBoard", "base-badge", "base-flagPin", + "base-crosshair", "base-scrollOpen", "base-diamond", "base-photo", + "base-fourStarShort", "base-circleStar", "base-lock", "base-crown", + "base-leaf", "base-signpost", "base-beer", "base-compass", "base-video", + "base-key", "base-chest", "base-village", "base-swordUp", "base-house", + "base-house2", "base-church", "base-government", "base-blacksmith", + "base-stable", "base-gear", "base-bridge", "base-mountain", + "base-exclamation", "base-question" + ], + + useTextIcon: "boolean", + iconText: "string", + + tooltipImageSize: ["small", "medium", "large", "xl"] + }; + + + 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 = {}; + + const originalCustomization = p.get("customizationType") || "icon"; + let newCustomization = originalCustomization; + let revertingFromText = false; + + Object.entries(flags).forEach(([key, raw]) => + { + const spec = PIN_SET_PROPERTIES[key]; + let value = raw; + + // Boolean + if(spec === "boolean") + { + value = raw === "true"; + } + + // Roll20 image validation + else if(spec === "roll20image") + { + if(value && !isValidRoll20Image(value)) throw 0; + } + + // Color validation + else if(spec === "color") + { + if(!/^(transparent|#[0-9a-fA-F]{6}([0-9a-fA-F]{2})?)$/.test(value)) + throw 0; + } + + // Simple numeric + 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 + 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)); + } + + // ---- Behavioral Rules ---- + + if(key === "pinImage") + { + if(value) + newCustomization = "image"; + } + +if(key === "icon") +{ + newCustomization = "icon"; + updates.useTextIcon = false; +} + + if(key === "iconText") + { + if(!value) + { + const title = updates.title ?? p.get("title") ?? ""; + value = title.substring(0, 3); + } + else + { + value = value.substring(0, 3); + } + + updates.useTextIcon = true; + } + +if(key === "useTextIcon") +{ + if(value === true) + { + newCustomization = "icon"; // text icons are a variation of icon mode + } + else + { + revertingFromText = true; + } +} + +if(key === "customizationType") +{ + newCustomization = value; + + if(value === "icon") + updates.useTextIcon = false; +} + + updates[key] = value; + }); + + // Final mode resolution (last flag wins) + if(revertingFromText) + { + updates.customizationType = originalCustomization; + } + else + { + updates.customizationType = newCustomization; + } + + // Prevent empty image mode + if(updates.customizationType === "image") + { + const finalImage = updates.pinImage ?? p.get("pinImage"); + if(!finalImage) + updates.customizationType = "icon"; + } + + 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).`); + } + +function deriveAutoText(str) +{ + if(!str) return ""; + + const startMatch = str.match(/[A-Za-z0-9]/); + if(!startMatch) return ""; + + const sliced = str.slice(startMatch.index); + + const tokenMatch = sliced.match(/^[A-Za-z0-9]+/); + if(!tokenMatch) return ""; + + const token = tokenMatch[0]; + + return token.length <= 3 + ? token + : token.substring(0, 3); +} + +function handleTransform(msg, argString) +{ + if(!argString) + return sendError("No transform specified."); + + const tokens = argString.split(/\s+/); + const transformType = tokens[0].toLowerCase(); + + if(transformType !== "autotext") + return sendError(`Unknown transform: ${transformType}`); + + // ---- Parse filter ---- + + let filterRaw = ""; + + const filterMatch = argString.match(/filter\|(.+)/i); + if(filterMatch) + filterRaw = filterMatch[1].trim(); + + const pageId = getPageForPlayer(msg.playerid); + + 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("Transform matched no pins on the current page."); + + 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 title = p.get("title") || ""; + const subLink = p.get("subLink") || ""; + + const source = title.trim() ? title : subLink; + const derived = deriveAutoText(source); + + if(!derived) return; + + p.set({ + customizationType: "icon", + useTextIcon: true, + iconText: derived + }); + + // force refresh + p.set({ layer: p.get("layer") }); + }); + + if(queue.length) + setTimeout(processBatch, 0); + }; + + processBatch(); +} + + + + + // ============================================================ + // 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 === "--library") +{ + // Rebuild everything after --library, preserving spaces + const argString = msg.content + .replace(/^!pintool\s+--library\s*/i, "") + .trim(); + + if(!argString) + return showLibraryKeywords(); + + if(argString.startsWith("keyword|")) + return showLibraryKeywordResults(argString.slice(8)); + + if(argString.startsWith("copy|")) + return copyLibraryPinToSelection(argString.slice(5), msg.selected); + + return sendError("Invalid --library syntax."); +} + + + if(cmd?.startsWith("--imagetochat|")) + return handleImageToChat(parts[1].slice(14)); + + if(cmd?.startsWith("--imagetochatall|")) + return handleImageToChatAll(parts[1].slice(17)); + + + if(cmd === "--transform") + { + const argString = msg.content + .replace(/^!pintool\s+--transform\s*/i, "") + .trim(); + + return handleTransform(msg, argString); + } + 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); + } +} \ No newline at end of file diff --git a/PinTool/PinTool.js b/PinTool/PinTool.js index d77a350a5..7d521588e 100644 --- a/PinTool/PinTool.js +++ b/PinTool/PinTool.js @@ -1,28 +1,46 @@ // 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 Huge update: Normalized headers with html entities, Added more transformation options on --set: math, and words for scale, Added advanced customization, pin style library, auto numbering + //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 ICON_SPRITE_URL = "https://files.d20.io/images/477999554/bETqvktx8A9TszRZBnmDWg/original.png?1772436951"; + const ICON_SIZE = 40; // original sprite slice size + const ICON_DISPLAY_SIZE = 20; // rendered size (50%) - const PINTOOL_HELP_TEXT = ` + const PINTOOL_HELP_TEXT = `

PinTool Script Help

@@ -49,6 +67,8 @@ It also provides commands for conversion of old-style note tokens to new

  • --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.
  • +
  • --library — Browse and copy saved pin styles from the Pin Library page.
  • +
  • --transform — Apply transformations to pins (currently supports automatic text icon generation).

  • @@ -121,6 +141,18 @@ See examples at the end of this document.

    Appearance

    • scale — Range: 0.252.0
    • +
    • Preset sizes: teeny, tiny, small, medium, large, huge, gigantic
    • +
    • bgColor — Background color (hex rgb or rgba for transparency) or transparent)
    • +
    • shapeteardrop, circle, diamond, square
    • +
    • tooltipImageSizesmall, medium, large, xl
    • +
    • Display Mode
    • +
    • customizationTypeicon or image
    • +
    • icon — Icon preset identifier
    • +
    • pinImage — Roll20 image URL for custom pin image
    • +
    • useTextIcontrue or false
    • +
    • iconText — Up to 3 characters displayed as a text icon
    • +

      Note, setting icon, iconText, or pinImage will automatically change the customizationType to match.

      +

    State

    @@ -281,6 +313,78 @@ The purge command removes all tokens on the map similar to the tokens or pins
    + +
    + +

    Transform Command

    + +

    +The transform command applies derived transformations to pins. +

    + +

    Format:

    +
    +!pintool --transform autotext [filter|target]
    +
    + +

    Supported Transforms

    + +
      +
    • + autotext
      + Derives up to 3 characters from the pin’s title (or subLink if title is empty) + and converts the pin into a text icon. +
    • +
    + +

    +Text is derived from the first alphanumeric characters in the title. +If no valid characters are found, the pin is not modified. +

    + +
    + +

    Pin Library

    + +

    +The library command allows you to browse and copy saved pin styles +from a dedicated page named Pin Library. +

    + +

    Format:

    +
    +!pintool --library
    +!pintool --library keyword|keyword
    +
    + +

    Setup

    + +
      +
    • Create a page named exactly Pin Library.
    • +
    • Create pins on that page configured with the styles you want to reuse.
    • +
    • Add keywords to each pin title in square brackets:
    • +
    + +
    +Camp [travel, wilderness]
    +Battle [combat, viking]
    +Treasure [loot]
    +
    + +

    Behavior

    + +
      +
    • !pintool --library lists all available keywords.
    • +
    • Selecting a keyword displays matching pin styles.
    • +
    • Clicking a style copies its appearance to selected pins.
    • +
    • Position, title, notes, and links are not overwritten.
    • +
    + +

    +If the Pin Library page does not exist or contains no valid keyworded pins, +the command will display an error. +

    +

    Example Macros

    @@ -292,6 +396,10 @@ The purge command removes all tokens on the map similar to the
  • !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
  • +
  • !pintool --set bgColor|#307bb8 shape|circle
    Sets selected pin color and shape
  • +
  • !pintool --set pinImage|https://...
    Sets custom pin image
  • +
  • !pintool --transform autotext
    Generates 3-letter text icons from titles
  • +
  • !pintool --library
    Browse saved pin styles

  • @@ -305,311 +413,525 @@ The purge command removes all tokens on the map similar to the `; - let sender; - - const getPageForPlayer = (playerid) => { - let player = getObj('player', playerid); - if (playerIsGM(playerid)) { - return player.get('lastpage') || Campaign().get('playerpageid'); - } + const ICON_ORDER = [ + "base-dot", + "base-castle", + "base-skullSimple", + "base-spartanHelm", + "base-radioactive", + "base-heart", + "base-star", + "base-starSign", + "base-pin", + "base-speechBubble", + "base-file", + "base-plus", + "base-circleCross", + "base-dartBoard", + "base-badge", + "base-flagPin", + "base-crosshair", + "base-scrollOpen", + "base-diamond", + "base-photo", + "base-fourStarShort", + "base-circleStar", + "base-lock", + "base-crown", + "base-leaf", + "base-signpost", + "base-beer", + "base-compass", + "base-video", + "base-key", + "base-chest", + "base-village", + "base-swordUp", + "base-house", + "base-house2", + "base-church", + "base-government", + "base-blacksmith", + "base-stable", + "base-gear", + "base-bridge", + "base-mountain", + "base-exclamation", + "base-question" + ]; + + + + + 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]; - } + 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}`); - } - - - 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 = - `
    ` + + sendChat("PinTool", `/w gm ${box}`); + } - `
    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") + - `
    ` + + 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;", + + iconSpriteButton: "display:inline-block;" + + "width:40px;" + + "height:40px;" + + "background-color:#000;" + // force black behind transparent png + "background-repeat:no-repeat;" + + "background-size:1760px 40px;" + + "border:1px solid #555;" + + "border-radius:2px;" + + "margin:1px;" + + "padding:0;" + + "line-height:0;" + + "font-size:0;" + + "text-decoration:none;" + + "vertical-align:top;", + + panelButtonLeft: "display:inline-block;" + + "padding:2px 6px;" + + "border-radius:6px;" + + "background:#333;" + + "border:1px solid #555;" + + "border-right:none;" + + "color:#9fd3ff;" + + "text-decoration:none;" + + "font-size:12px;" + + "margin:0 2px 4px 0px;", + + 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;", + + colorButton: "display:inline-block;" + + "width:20px;" + + "height:20px;" + + "border:1px solid #555;" + + "border-radius:2px;" + + "margin:1px;" + + "padding:0;" + + "vertical-align:middle;" + + "text-decoration:none;", + + libraryPinButton: "display:block;" + + "margin:4px 0;" + + "padding:4px;" + + "border-radius:4px;" + + "background:#2a2a2a;" + + "border:1px solid #555;" + + "color:#fff;" + + "text-decoration:none;" + + "font-size:12px;" + + "white-space:nowrap;", + + libraryPinVisual: "display:inline-block;" + + "width:35px;" + + "height:35px;" + + "margin-right:6px;" + + "vertical-align:middle;" + + "border:1px solid #555;" + + "border-radius:4px;" + + "background-color:#000;", + + libraryPinText: "display:inline-block;" + + "vertical-align:middle;" + }; + } - `
    Visible
    ` + - splitButton("GM Only", "!pintool --set visibleTo|") + - splitButton("All Players", "!pintool --set visibleTo|all") + - `
    ` + + function splitButton(label, command) + { + const css = getCSS(); - `
    Blockquote as player text
    ` + - splitButton("On", "!pintool --set autoNotesType|blockquote") + - splitButton("Off", "!pintool --set autoNotesType|") + - `
    ` + + return ( + `${label}` // + + //`++` + ); + } - `
    Display
    ` + - splitButton("From Handout", "!pintool --set imageDesynced|false imageVisibleTo|") + - splitButton("Custom", "!pintool --set imageDesynced|true imageVisibleTo|all") + - `
    ` + + function iconSpriteButton(index, iconValue) + { + const offsetX = -(index * ICON_DISPLAY_SIZE); + + return ` +
    + + +
    + `; + } - `
    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}") + - `
    ` + + function messageButton(label, command) + { + const css = getCSS(); - `
    Delete All Pins on Page
    Select an example pin first.
    ` + - messageButton("Delete All Pins on Page", "!pintool --purge pins") + - `
    ` + + return ( + `${label}` + ); + } - `
    `; + function showControlPanel() + { + const css = getCSS(); + + const colors = [ + "#242424", "#307bb8", "#721211", "#e59a00", "#b40f69", "#2d0075", "#e26608", "#588b02", "#bb1804", + "#ffffff", "#000000" + ]; + + const colorButtons = colors.map((c, i) => + (i === colors.length - 2 ? "
    " : "") + + `` + ).join(''); + + const panel = + `
    ` + + + // SIZE + `
    Size
    ` + + splitButton("Teeny", "!pintool --set scale|teeny") + + splitButton("Tiny", "!pintool --set scale|tiny") + + splitButton("Small", "!pintool --set scale|small") + + splitButton("Medium", "!pintool --set scale|medium") + + `
    ` + + splitButton("Large", "!pintool --set scale|large") + + splitButton("Huge", "!pintool --set scale|huge") + + splitButton("Gigantic", "!pintool --set scale|gigantic") + + `
    ` + + + // VISIBILITY + `
    Visible
    ` + + splitButton("GM Only", "!pintool --set visibleTo|") + + splitButton("All Players", "!pintool --set visibleTo|all") + + `
    ` + + + // BLOCKQUOTE + `
    Blockquote as player text
    ` + + splitButton("On", "!pintool --set autoNotesType|blockquote") + + splitButton("Off", "!pintool --set autoNotesType|") + + `
    ` + + + // DISPLAY SYNC + `
    Display
    ` + + splitButton("From Handout", "!pintool --set imageDesynced|false imageVisibleTo|") + + splitButton("Custom", "!pintool --set imageDesynced|true imageVisibleTo|all") + + `
    ` + + + // CUSTOMIZATION MODE + `
    Customization Mode
    ` + + splitButton("Icon", "!pintool --set customizationType|icon") + + splitButton("Image", "!pintool --set customizationType|image") + + splitButton("Text", "!pintool --set useTextIcon|true") + + splitButton("Set Text", "!pintool --set iconText|?{Input up to 3 characters}") + `
    ` + + splitButton("Pin Text from Title", "!pintool --transform autotext") + + splitButton("Hide Names", "!pintool --set nameplateVisibleTo|") + + + + `
    ` + + + + // ICON QUICK PICKS + `
    Icon Presets
    ` + + ICON_ORDER.map((icon, i) => iconSpriteButton(i, icon)).join("") + + `
    ` + + + // PIN IMAGE + `
    Pin Image
    ` + + splitButton("Set Pin Image", "!pintool --set pinImage|?{Roll20 Image URL}") + + splitButton("Clear Image", "!pintool --set pinImage| customizationType|icon") + + `
    ` + + + // TOOLTIP IMAGE + `
    Tooltip Image
    ` + + splitButton("Set Tooltip Image", "!pintool --set tooltipImage|?{Roll20 Image URL}") + + splitButton("S", "!pintool --set tooltipImageSize|small") + + splitButton("M", "!pintool --set tooltipImageSize|medium") + + splitButton("L", "!pintool --set tooltipImageSize|large") + + splitButton("XL", "!pintool --set tooltipImageSize|xl") + + `
    ` + + + // SHAPE + `
    Shape
    ` + + splitButton("Teardrop", "!pintool --set shape|teardrop") + + splitButton("Circle", "!pintool --set shape|circle") + + splitButton("Diamond", "!pintool --set shape|diamond") + + splitButton("Square", "!pintool --set shape|square") + + `
    ` + + + // BACKGROUND COLOR + `
    Pin Colors
    ` + + colorButtons + + splitButton("Transparent", "!pintool --set bgColor|") + + splitButton("Custom Color", "!pintool --set bgColor|?{Enter custom color (hex or transparent)}") + + `
    ` + + + // Pin LIbrary + `
    Pin Library ` + + splitButton("See Styles", "!pintool --library") + + `
    ` + + + // SCALE PLACEMENT + `
    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 FROM HANDOUT + `
    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}") + + `
    ` + + + `
    `; - sendStyledMessage( - "PinTool Control Panel", - panel - ); - } + sendStyledMessage( + "PinTool Control Panel", + panel + ); + } - function handlePurge(msg, args) { - if (!args.length) return; + function handlePurge(msg, args) + { + if(!args.length) return; - const mode = args[0]; - if (mode !== "tokens" && mode !== "pins") return; + const mode = args[0]; + if(mode !== "tokens" && mode !== "pins") return; - const confirmed = args.includes("--confirm"); + const confirmed = args.includes("--confirm"); - // -------------------------------- - // CONFIRM PATH (no selection) - // -------------------------------- - if (confirmed) { - let charId, handoutId, pageId; + // -------------------------------- + // CONFIRM PATH (no selection) + // -------------------------------- + if(confirmed) + { + let charId, handoutId, pageId; - args.forEach(a => { - if (a.startsWith("char|")) charId = a.slice(5); - if (a.startsWith("handout|")) handoutId = a.slice(8); - if (a.startsWith("page|")) pageId = a.slice(5); - }); + args.forEach(a => + { + if(a.startsWith("char|")) charId = a.slice(5); + if(a.startsWith("handout|")) handoutId = a.slice(8); + if(a.startsWith("page|")) pageId = a.slice(5); + }); - if (!pageId) return; + if(!pageId) return; - /* ===== PURGE TOKENS (CONFIRM) ===== */ - if (mode === "tokens" && charId) { - const char = getObj("character", charId); - if (!char) return; + /* ===== PURGE TOKENS (CONFIRM) ===== */ + if(mode === "tokens" && charId) + { + const char = getObj("character", charId); + if(!char) return; - const charName = char.get("name") || "Unknown Character"; + const charName = char.get("name") || "Unknown Character"; - const targets = findObjs( - { - _type: "graphic", - _subtype: "token", - _pageid: pageId, - represents: charId - }); + const targets = findObjs( + { + _type: "graphic", + _subtype: "token", + _pageid: pageId, + represents: charId + }); - if (!targets.length) return; + if(!targets.length) return; - targets.forEach(t => t.remove()); + targets.forEach(t => t.remove()); - sendChat( - "PinTool", - `/w gm ✅ Deleted ${targets.length} token(s) for "${_.escape(charName)}".` - ); - } + sendChat( + "PinTool", + `/w gm ✅ Deleted ${targets.length} token(s) for "${_.escape(charName)}".` + ); + } - /* ===== PURGE PINS (CONFIRM) ===== */ - if (mode === "pins" && handoutId) { - const handout = getObj("handout", handoutId); - if (!handout) return; + /* ===== PURGE PINS (CONFIRM) ===== */ + if(mode === "pins" && handoutId) + { + const handout = getObj("handout", handoutId); + if(!handout) return; - const handoutName = handout.get("name") || "Unknown Handout"; + const handoutName = handout.get("name") || "Unknown Handout"; - const targets = findObjs( - { - _type: "pin", - _pageid: pageId - }).filter(p => p.get("link") === handoutId); + const targets = findObjs( + { + _type: "pin", + _pageid: pageId + }).filter(p => p.get("link") === handoutId); - if (!targets.length) return; + if(!targets.length) return; - const count = targets.length; + const count = targets.length; - const burndown = () => { - let p = targets.shift(); - if (p) { - p.remove(); - setTimeout(burndown, 0); - } else { - sendChat( - "PinTool", - `/w gm ✅ Deleted ${count} pin(s) linked to "${_.escape(handoutName)}".` - ); - } - }; - burndown(); - } + const 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 +951,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,788 +989,1551 @@ 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 - } - ); - }; - - function sendError(msg) { - sendStyledMessage("PinTool — Error", msg); - } - - function sendWarning(msg) { - sendStyledMessage("PinTool — Warning", msg); - } - - // ============================================================ - // IMAGE → CHAT - // ============================================================ - - function handleImageToChat(encodedUrl) { - let url = encodedUrl.trim().replace(/^(https?)!!!/i, (_, p) => `${p}://`); - if (!/^https?:\/\//i.test(url)) return sendError("Invalid image URL."); - - const imageHtml = - `
    ` + - `` + - `
    ` + - `` + - `Send to All` + - `
    ` + - `
    `; - - sendChat("PinTool", `/w "${sender}" ${imageHtml}`, - null, - { noarchive: true }); - } - - - function handleImageToChatAll(encodedUrl) { - let url = encodedUrl.trim().replace(/^(https?)!!!/i, (_, p) => `${p}://`); - if (!/^https?:\/\//i.test(url)) return sendError("Invalid image URL."); - - sendChat( - "PinTool", `
    `, - null, - { noarchive: true }); - } - - // ============================================================ - // SET MODE (pins) - // ============================================================ - - const PIN_SET_PROPERTIES = { - x: "number", - y: "number", - title: "string", - notes: "string", - image: "string", - tooltipImage: "string", - link: "string", - linkType: ["", "handout"], - subLink: "string", - subLinkType: ["", "headerPlayer", "headerGM"], - visibleTo: ["", "all"], - tooltipVisibleTo: ["", "all"], - nameplateVisibleTo: ["", "all"], - imageVisibleTo: ["", "all"], - notesVisibleTo: ["", "all"], - gmNotesVisibleTo: ["", "all"], - autoNotesType: ["", "blockquote"], - scale: - { - min: 0.25, - max: 2.0 - }, - imageDesynced: "boolean", - notesDesynced: "boolean", - gmNotesDesynced: "boolean" - }; - - function handleSet(msg, tokens) { - const flags = {}; - let filterRaw = ""; - - for (let i = 0; i < tokens.length; i++) { - const t = tokens[i]; - const idx = t.indexOf("|"); - if (idx === -1) continue; + sendChat( + scriptName, + `${isPublic ? "" : "/w gm "}${normalizeForChat(html)}`, + null, + { + noarchive: true + } + ); + }; - const key = t.slice(0, idx); - let val = t.slice(idx + 1); + function sendError(msg) + { + sendStyledMessage("PinTool — Error", msg); + } - 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; + function sendWarning(msg) + { + sendStyledMessage("PinTool — Warning", msg); } - if (!Object.keys(flags).length) - return sendError("No valid properties supplied to --set."); +//Pin library functions + function parseLibraryTitle(title) + { + const match = title.match(/\[(.*?)\]/); + if(!match) return null; + const keywordBlock = match[1]; + const keywords = keywordBlock + .split(',') + .map(k => k.trim().toLowerCase()) + .filter(k => k.length); - const pageId = getPageForPlayer(msg.playerid); - /* - (Campaign().get("playerspecificpages") || {})[msg.playerid] || - Campaign().get("playerpageid"); -*/ + const cleanTitle = title.replace(/\s*\[.*?\]\s*/, '').trim(); - let pins = []; + return { + cleanTitle, + keywords + }; + } - 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( + function getLibraryPage() { - _type: "pin", - _pageid: pageId - }); - } - else { - pins = filterRaw.split(/\s+/) - .map(id => getObj("pin", id)) - .filter(p => p && p.get("_pageid") === pageId); - } - - if (!pins.length) - return sendWarning("Filter matched no pins on the current page."); - - const updates = {}; - try { - Object.entries(flags).forEach(([key, raw]) => { - const spec = PIN_SET_PROPERTIES[key]; - let value = raw; - - if (spec === "boolean") value = raw === "true"; - else if (spec === "number") value = Number(raw); - else if (Array.isArray(spec) && !spec.includes(value)) throw 0; - else if (!Array.isArray(spec) && typeof spec === "object") { - value = Number(raw); - if (value < spec.min || value > spec.max) throw 0; + return findObjs( + { + _type: "page", + name: "Pin Library" + })[0]; } - updates[key] = value; - }); - } - catch { - return sendError("Invalid value supplied to --set."); - } - pins.forEach(p => p.set(updates)); - //sendStyledMessage("PinTool — Success", `Updated ${pins.length} pin(s).`); - } - - // ============================================================ - // CONVERT MODE (tokens → handout) - // ============================================================ - - function sendConvertHelp() { - sendStyledMessage( - "PinTool — Convert", - "Usage
    !pintool --convert name|h2 title|My Handout [options]" - ); - } - - // ============================================================ - // CONVERT MODE - // ============================================================ - - function handleConvert(msg, tokens) { - - if (!tokens.length) { - sendConvertHelp(); - return; - } - // ---------------- Parse convert specs (greedy tail preserved) ---------------- - const flags = {}; - const orderedSpecs = []; - for (let i = 0; i < tokens.length; i++) { - const t = tokens[i]; - const idx = t.indexOf("|"); - if (idx === -1) continue; +function showLibraryKeywords() +{ + const css = getCSS(); + const page = getLibraryPage(); - const key = t.slice(0, idx).toLowerCase(); - let val = t.slice(idx + 1); + if(!page) { + sendError("Pin Library page not found. Create a page named 'Pin Library' and add pins with keywords. See !pintool --help for details."); + return; + } - const parts = [val]; - let j = i + 1; + const pins = findObjs( + { + _type: "pin", + _pageid: page.id + }); - while (j < tokens.length) { - const next = tokens[j]; - if (next.indexOf("|") !== -1) break; - parts.push(next); - j++; - } + const keywordSet = new Set(); - val = parts.join(" "); - flags[key] = val; - orderedSpecs.push( - { - key, - val - }); - i = j - 1; - } + pins.forEach(pin => + { + const parsed = parseLibraryTitle(pin.get("title")); + if(!parsed) return; - // ---------------- Required args ---------------- - if (!flags.title) return sendError("--convert requires title|"); - if (!flags.name) return sendError("--convert requires name|h1–h5"); + parsed.keywords.forEach(k => keywordSet.add(k)); + }); - const nameMatch = flags.name.match(/^h([1-5])$/i); - if (!nameMatch) return sendError("name must be h1 through h5"); + const keywords = Array.from(keywordSet).sort(); - const nameHeaderLevel = parseInt(nameMatch[1], 10); - const minAllowedHeader = Math.min(nameHeaderLevel + 1, 6); + if(keywords.length === 0) { + sendError("No pins with keywords found on the Pin Library page. See !pintool --help to create them."); + return; + } - const supernotes = flags.supernotesgmtext === "true"; - const imagelinks = flags.imagelinks === "true"; - const replace = flags.replace === "true"; // NEW + const buttons = keywords.map(k => + `${k}` + ).join("
    "); - // ---------------- Token validation ---------------- - if (!msg.selected || !msg.selected.length) { - sendError("Please select a token."); - return; - } + const output = + `
    +
    Pin Library
    +
    + ${buttons} +
    +
    +${messageButton("Main Menu", "!pintool")} +
    +
    `; - const selectedToken = getObj("graphic", msg.selected[0]._id); - if (!selectedToken) return sendError("Invalid token selection."); + sendChat("PinTool", `/w gm ${output}`); +} - 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 buildLibraryPinButton(pin) { + const css = getCSS(); + const title = pin.get("title"); + const parsed = parseLibraryTitle(title); + if (!parsed) return ""; + + const cleanTitle = parsed.cleanTitle; + + const useTextIcon = pin.get("useTextIcon"); + const customizationType = pin.get("customizationType"); + const pinImage = pin.get("pinImage"); + const icon = pin.get("icon"); + const bgColor = pin.get("bgColor") || "#000"; + const iconText = pin.get("iconText"); + + let visual = ""; + + // Base styles for the visual div + const baseStyle = ` + width:35px; + height:35px; + display:inline-block; + vertical-align:middle; + border-radius:4px; + text-align:center; + line-height:35px; + font-weight:bold; + overflow:hidden; + background-size: auto 100%; + `; + + if (useTextIcon === true && iconText) { + // Text Icon + visual = `
    ${iconText.substring(0,3)}
    `; + } + else if (customizationType === "image" && pinImage) { + // Image pin — always light neutral gray behind + const grayBg = "#ccc"; + visual = `
    +
    `; + } +else if (customizationType === "icon" && icon) { + const iconIndex = ICON_ORDER.indexOf(icon); + const totalIcons = ICON_ORDER.length; + const bgPosPercent = (iconIndex / (totalIcons - 1)) * 100; + + visual = `
    +
    `; +} + else { + // Only color + visual = `
    `; } - function normalizeVisibleText(html) { - return html - .replace(//gi, "\n") - .replace(/<\/p\s*>/gi, "\n") - .replace(/<[^>]+>/g, "") - .replace(/ /gi, " ") - .replace(/\s+/g, " ") - .trim(); - } + return ` + ${visual} + ${cleanTitle} + `; +} - function applyBlockquoteSplit(html) { - const blocks = html.match(//gi); - if (!blocks) return `
    ${html}
    `; - const idx = blocks.findIndex( - b => normalizeVisibleText(b) === "-----" - ); + function showLibraryKeywordResults(keyword) + { + const css = getCSS(); + const page = getLibraryPage(); + if(!page) return; - // NEW: no separator → everything is player-visible - if (idx === -1) { - return `
    ${blocks.join("")}
    `; - } + const lower = keyword.toLowerCase(); - // Separator exists → split as before - const player = blocks.slice(0, idx).join(""); - const gm = blocks.slice(idx + 1).join(""); + const pins = findObjs( + { + _type: "pin", + _pageid: page.id + }); - return `
    ${player}
    \n${gm}`; - } + const matches = pins.filter(pin => + { + const parsed = parseLibraryTitle(pin.get("title")); + if(!parsed) return false; + return parsed.keywords.includes(lower); + }); + matches.sort((a, b) => + { + const pa = parseLibraryTitle(a.get("title")); + const pb = parseLibraryTitle(b.get("title")); + return pa.cleanTitle.localeCompare(pb.cleanTitle); + }); - function downgradeHeaders(html) { - return html - .replace(/<\s*h[1-2]\b[^>]*>/gi, "

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

    "); - } + const buttons = matches.map(buildLibraryPinButton).join(""); - function encodeProtocol(url) { - return url.replace(/^(https?):\/\//i, "$1!!!"); - } + const output = + `
    +
    Keyword: ${keyword}
    +
    + ${buttons} +
    +
    +${splitButton("Change Keyword", "!pintool --library")} + ${splitButton("Main Menu", "!pintool")} +
    +
    `; - 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; + sendChat("PinTool", `/w gm ${output}`); } - ); - - 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, + function copyLibraryPinToSelection(pinId, selected) { - sensitivity: "base" - })); + const libraryPin = getObj("pin", pinId); + if(!libraryPin) return; + const targets = (selected || []) + .map(s => getObj(s._type, s._id)) + .filter(o => o && o.get("_type") === "pin"); - 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; + if(!targets.length) + { + sendChat("PinTool", `/w gm No pins selected.`); + return; + } - sendChat("PinTool", `/w gm Handout "${flags.title}" updated.`); + const props = libraryPin.attributes; - if (!replace) return; + targets.forEach(target => + { + Object.keys(props).forEach(key => + { + if([ + "title", + "link", + "linkType", + "subLink", + "subLinkType", + "_id", + "_type", + "x", + "y", + "notes", + "gmNotes", + "y", + "y", + "_pageid" + ].includes(key)) return; + + target.set(key, props[key]); + }); + }); + } - 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 - }); + // ============================================================ + // IMAGE → CHAT + // ============================================================ + const isValidRoll20Image = (url) => + { + return typeof url === 'string' && url.includes('files.d20.io/images'); + }; - 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.` - ); - } + function handleImageToChat(encodedUrl) + { + let url = encodedUrl.trim().replace(/^(https?)!!!/i, (_, p) => `${p}://`); + if(!/^https?:\/\//i.test(url)) + { + return sendError("Invalid image URL."); } - }; - 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(); - } - }; + const isRoll20Image = isValidRoll20Image(url); - burndown(); + let buttons = + `` + + `Send to All`; - } + if(isRoll20Image) + { + buttons += + ` ` + + `Place image in Pin`; + } - // ============================================================ - // PLACE MODE - // ============================================================ + const imageHtml = + `
    ` + + `` + + `
    ${buttons}
    ` + + `
    `; - function handlePlace(msg, args) { + sendChat( + "PinTool", + `/w "${sender}" ${imageHtml}`, + null, + { + noarchive: true + } + ); + } - 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; + 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", + + tooltipImage: "roll20image", + pinImage: "roll20image", + + 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", + + bgColor: "color", + shape: ["teardrop", "circle", "diamond", "square"], + + customizationType: ["icon", "image"], + icon: [ + "base-dot", "base-castle", "base-skullSimple", "base-spartanHelm", + "base-radioactive", "base-heart", "base-star", "base-starSign", + "base-pin", "base-speechBubble", "base-file", "base-plus", + "base-circleCross", "base-dartBoard", "base-badge", "base-flagPin", + "base-crosshair", "base-scrollOpen", "base-diamond", "base-photo", + "base-fourStarShort", "base-circleStar", "base-lock", "base-crown", + "base-leaf", "base-signpost", "base-beer", "base-compass", "base-video", + "base-key", "base-chest", "base-village", "base-swordUp", "base-house", + "base-house2", "base-church", "base-government", "base-blacksmith", + "base-stable", "base-gear", "base-bridge", "base-mountain", + "base-exclamation", "base-question" + ], + + useTextIcon: "boolean", + iconText: "string", + + tooltipImageSize: ["small", "medium", "large", "xl"] + }; + + + 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 = []; - const key = t.slice(0, idx).toLowerCase(); - let val = t.slice(idx + 1); + 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); + } - const parts = [val]; - let j = i + 1; + 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; - while (j < args.length && args[j].indexOf("|") === -1) { - parts.push(args[j]); - j++; - } + const processBatch = () => + { + const slice = queue.splice(0, BATCH_SIZE); - flags[key] = parts.join(" "); - i = j - 1; + slice.forEach(id => + { + const p = getObj("pin", id); + if(!p) return; + + const updates = {}; + + const originalCustomization = p.get("customizationType") || "icon"; + let newCustomization = originalCustomization; + let revertingFromText = false; + + Object.entries(flags).forEach(([key, raw]) => + { + const spec = PIN_SET_PROPERTIES[key]; + let value = raw; + + // Boolean + if(spec === "boolean") + { + value = raw === "true"; + } + + // Roll20 image validation + else if(spec === "roll20image") + { + if(value && !isValidRoll20Image(value)) throw 0; + } + + // Color validation + else if(spec === "color") + { + if(!/^(transparent|#[0-9a-fA-F]{6}([0-9a-fA-F]{2})?)$/.test(value)) + throw 0; + } + + // Simple numeric + 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 + 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)); + } + + // ---- Behavioral Rules ---- + + if(key === "pinImage") + { + if(value) + newCustomization = "image"; + } + +if(key === "icon") +{ + newCustomization = "icon"; + updates.useTextIcon = false; +} + + if(key === "iconText") + { + if(!value) + { + const title = updates.title ?? p.get("title") ?? ""; + value = title.substring(0, 3); + } + else + { + value = value.substring(0, 3); + } + + updates.useTextIcon = true; + } + +if(key === "useTextIcon") +{ + if(value === true) + { + newCustomization = "icon"; // text icons are a variation of icon mode + } + else + { + revertingFromText = true; } +} + +if(key === "customizationType") +{ + newCustomization = value; + + if(value === "icon") + updates.useTextIcon = false; +} + + updates[key] = value; + }); + + // Final mode resolution (last flag wins) + if(revertingFromText) + { + updates.customizationType = originalCustomization; + } + else + { + updates.customizationType = newCustomization; + } + + // Prevent empty image mode + if(updates.customizationType === "image") + { + const finalImage = updates.pinImage ?? p.get("pinImage"); + if(!finalImage) + updates.customizationType = "icon"; + } + + p.set(updates); + p.set( + { + layer: p.get("layer") + }); - 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(queue.length) + { + setTimeout(processBatch, 0); + } + }; - const headerLevel = parseInt(nameMatch[1], 10); - const handoutName = flags.handout; + processBatch(); + } + catch + { + return sendError("Invalid value supplied to --set."); + } - /* ---------------- 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; + //sendStyledMessage("PinTool — Success", `Updated ${pins.length} pin(s).`); + } + +function deriveAutoText(str) +{ + if(!str) return ""; + + const startMatch = str.match(/[A-Za-z0-9]/); + if(!startMatch) return ""; + + const sliced = str.slice(startMatch.index); + + const tokenMatch = sliced.match(/^[A-Za-z0-9]+/); + if(!tokenMatch) return ""; + + const token = tokenMatch[0]; + + return token.length <= 3 + ? token + : token.substring(0, 3); +} + +function handleTransform(msg, argString) +{ + if(!argString) + return sendError("No transform specified."); + + const tokens = argString.split(/\s+/); + const transformType = tokens[0].toLowerCase(); + + if(transformType !== "autotext") + return sendError(`Unknown transform: ${transformType}`); + + // ---- Parse filter ---- + + let filterRaw = ""; + + const filterMatch = argString.match(/filter\|(.+)/i); + if(filterMatch) + filterRaw = filterMatch[1].trim(); - /* ---------------- Page ---------------- */ const pageId = getPageForPlayer(msg.playerid); - if (typeof pageId === "undefined") - return sendError("pageId is not defined."); + 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("Transform matched no pins on the current page."); + + 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 title = p.get("title") || ""; + const subLink = p.get("subLink") || ""; + + const source = title.trim() ? title : subLink; + const derived = deriveAutoText(source); - const page = getObj("page", pageId); - if (!page) return sendError("Invalid pageId."); + if(!derived) return; - const gridSize = page.get("snapping_increment") * 70 || 70; - const maxCols = Math.floor((page.get("width") * 70) / gridSize); + p.set({ + customizationType: "icon", + useTextIcon: true, + iconText: derived + }); + + // force refresh + p.set({ layer: p.get("layer") }); + }); + + if(queue.length) + setTimeout(processBatch, 0); + }; - const startX = gridSize / 2; - const startY = gridSize / 2; + processBatch(); +} - let col = 0; - let row = 0; - /* ---------------- Header extraction ---------------- */ - const headerRegex = new RegExp( - `([\\s\\S]*?)<\\/h${headerLevel}>`, - "gi" - ); - const headers = []; // { text, subLinkType } - function extractHeaders(html, subLinkType) { - let m; - while ((m = headerRegex.exec(html)) !== null) { - headers.push( - { - text: _.unescape(m[1]).trim(), - subLinkType - }); - } + // ============================================================ + // CONVERT MODE (tokens → handout) + // ============================================================ + + function sendConvertHelp() + { + sendStyledMessage( + "PinTool — Convert", + "Usage
    !pintool --convert name|h2 title|My Handout [options]" + ); } - handout.get("notes", html => extractHeaders(html, "headerPlayer")); - handout.get("gmnotes", html => extractHeaders(html, "headerGM")); + // ============================================================ + // CONVERT MODE + // ============================================================ - if (!headers.length) - return sendError(`No headers found in handout.`); + function handleConvert(msg, tokens) + { - /* ---------------- 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; - }); + 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"); - 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++; + 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; } - else { - x = startX + col * gridSize; - // Stagger every other pin in the row by 20px vertically - y = startY + row * gridSize + (col % 2 ? 20 : 0); + 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."); - col++; - if (col >= maxCols) { - col = 0; - row++; - } + const tokensOnPage = findObjs( + { + _type: "graphic", + _subtype: "token", + _pageid: pageId, + represents: charId + }); + if(!tokensOnPage.length) + { + sendError("No matching map tokens found."); + return; + } - // Two-step creation (same defaults as convert) - createObj("pin", + // ---------------- 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 { - pageid: pageId, - x: x, - y: y, - link: handoutId, - linkType: "handout", - subLink: headerText, - subLinkType: subLinkType, - autoNotesType: "blockquote", - scale: 1, - notesDesynced: false, - imageDesynced: false, - gmNotesDesynced: false + 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 }); - created++; + + 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; } - setTimeout(burndown, 0); - } else { - /* ---------------- Report ---------------- */ - sendStyledMessage( - "Place Pins", - `

    Handout: ${_.escape(handoutName)}

    + + 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(); + ); + } + }; + burndown(); - } + } + // ============================================================ + // CHAT DISPATCH + // ============================================================ - // ============================================================ - // CHAT DISPATCH - // ============================================================ + on("chat:message", msg => + { + if(msg.type !== "api" || !/^!pintool\b/i.test(msg.content)) return; - 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(); + sender = msg.who.replace(/\s\(GM\)$/, ''); - if (parts.length === 1) { - showControlPanel(); - return; - } + 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 === "--library") +{ + // Rebuild everything after --library, preserving spaces + const argString = msg.content + .replace(/^!pintool\s+--library\s*/i, "") + .trim(); + + if(!argString) + return showLibraryKeywords(); + + if(argString.startsWith("keyword|")) + return showLibraryKeywordResults(argString.slice(8)); + + if(argString.startsWith("copy|")) + return copyLibraryPinToSelection(argString.slice(5), msg.selected); + + return sendError("Invalid --library syntax."); +} + + + if(cmd?.startsWith("--imagetochat|")) + return handleImageToChat(parts[1].slice(14)); + + if(cmd?.startsWith("--imagetochatall|")) + return handleImageToChatAll(parts[1].slice(17)); + + + if(cmd === "--transform") + { + const argString = msg.content + .replace(/^!pintool\s+--transform\s*/i, "") + .trim(); + + return handleTransform(msg, argString); + } + sendError("Unknown subcommand. Use --help."); + }); - if (cmd === "--set") return handleSet(msg, parts.slice(2)); - if (cmd === "--convert") return handleConvert(msg, parts.slice(2)); - if (cmd === "--place") return handlePlace(msg, parts.slice(2)); - if (cmd === "--purge") return handlePurge(msg, parts.slice(2)); - if (cmd === "--help") return handleHelp(msg); - if (cmd?.startsWith("--imagetochat|")) - return handleImageToChat(parts[1].slice(14)); - if (cmd?.startsWith("--imagetochatall|")) - return handleImageToChatAll(parts[1].slice(17)); - - sendError("Unknown subcommand. Use --help."); - }); }); -{ try { throw new Error(''); } catch (e) { API_Meta.PinTool.lineCount = (parseInt(e.stack.split(/\n/)[1].replace(/^.*:(\d+):.*$/, '$1'), 10) - API_Meta.PinTool.offset); } } +{ + try + { + throw new Error(''); + } + catch (e) + { + API_Meta.PinTool.lineCount = (parseInt(e.stack.split(/\n/)[1].replace(/^.*:(\d+):.*$/, '$1'), 10) - API_Meta.PinTool.offset); + } +} \ No newline at end of file diff --git a/PinTool/readme.md b/PinTool/readme.md index 69b238c9d..7f0282e0d 100644 --- a/PinTool/readme.md +++ b/PinTool/readme.md @@ -1,39 +1,42 @@ # PinTool -PinTool 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. +PinTool 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 into Roll20’s newer map pin system, allowing structured handouts and pins to stay in sync. --- ## Core Capabilities -- Bulk modification of map pin properties -- Precise targeting of selected pins, all pins on a page, or explicit pin IDs -- Conversion of legacy note tokens into structured handouts -- Automatic placement of map pins from handout headers (player and GM) -- Optional chat display of images referenced in notes +- Bulk modification of map pin properties. +- Precise targeting of selected pins, all pins on a page, or explicit pin IDs. +- Conversion of legacy token notes into structured handouts. +- Automatic placement of map pins from handout headers (player and GM). +- Optional chat display of images referenced in notes. +- **Pin Library (`--library`)** lets GMs Keep a library of pin styles for quick application. -**Base Command:** `!pintool` opens a control panel for commonly used editing controls. Add priaru commands afterward to access specific functions. - -`!pintool --help` creates a handout with full documentation +**Base Command:** `!pintool` opens a control panel for commonly used editing controls. Add primary commands afterward to access specific functions. +`!pintool --help` creates a handout with full documentation. --- ## Primary Commands -- `--set` updates one or more properties across many pins at once. -- `--convert` extracts data from tokens representing the same character and builds or updates a handout. -- `--place` scans a handout for headers and creates or replaces pins linked directly to those sections. -- `--purge` removes related tokens or pins in bulk. -- `--help` creates full documentation handout. +- `--set` — Update one or more properties across many pins at once. +- `--convert` — Extract data from tokens representing the same character and build or update a handout. +- `--place` — Create or replace pins based on handout headers, linking directly to those sections. +- `--purge` — Remove related tokens or pins in bulk. +- `--library` — Open the Pin Library to copy preset pin styles to selected pins. +- `--transform` — Apply transformations to pins, e.g., auto-generating icon text from titles. +- `--help` — Display the full PinTool help panel. --- ## Highlights -- Pins created via `--place` link directly to specific headers in Notes or GM Notes. -- Existing pins are replaced in-place, preserving their positions. -- Conversion supports header levels, blockquotes, code blocks, and inline image links. -- Visibility, scale, links, and sync state can all be controlled programmatically. +- Pins created via `--place` link directly to specific headers in Notes or GM Notes. +- Existing pins are replaced in-place, preserving their positions. +- Conversion supports header levels, blockquotes, code blocks, and inline image links. +- Visibility, scale, links, and sync state can all be controlled programmatically. +- Pin customization modes allow you to quickly switch the pin image between icons, text icons, or images. -Designed for GMs who want more automated control over pin placement and management. +Designed for GMs who want more automated control over pin placement, appearance, and management. \ No newline at end of file diff --git a/PinTool/script.json b/PinTool/script.json index 88614b3e4..e06cd8ed6 100644 --- a/PinTool/script.json +++ b/PinTool/script.json @@ -1,8 +1,8 @@ { "name": "PinTool", "script": "PinTool.js", - "version": "1.0.2", - "description": "# PinTool\n\nPinTool is a GM-only Roll20 API script for creating, inspecting, converting, and managing **map pins** at scale. It can convert older token-based note workflows with Roll20’s newer map pin system, allowing structured handouts and pins to stay in sync.\n\n---\n\n## Core Capabilities\n\n- Bulk modification of map pin properties\n- Precise targeting of selected pins, all pins on a page, or explicit pin IDs\n- Conversion of legacy note tokens into structured handouts\n- Automatic placement of map pins from handout headers (player and GM)\n- Optional chat display of images referenced in notes\n\n**Base Command:** `!pintool`\n\n---\n\n## Primary Commands\n\n```\n!pintool --set\n!pintool --convert\n!pintool --place\n!pintool --purge\n!pintool --help\n```\n\n- `--set` updates one or more properties across many pins at once.\n- `--convert` extracts data from tokens representing the same character and builds or updates a handout.\n- `--place` scans a handout for headers and creates or replaces pins linked directly to those sections.\n- `--purge` removes related tokens or pins in bulk.\n\n---\n\n## Highlights\n\n- Pins created via `--place` link directly to specific headers in Notes or GM Notes.\n- Existing pins are replaced in-place, preserving their positions.\n- Conversion supports header levels, blockquotes, code blocks, and inline image links.\n- Visibility, scale, links, and sync state can all be controlled programmatically.\n\nDesigned for GMs who want more automated control over pin placement and management.\n\nType **!pintool** in chat for a handy control panel.", + "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 into 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 token notes 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- **Pin Library (`--library`)** lets GMs Keep a library of pin styles for quick application.\\n\\n**Base Command:** `!pintool` opens a control panel for commonly used editing controls. Add primary commands afterward to access specific functions.\\n\\n`!pintool --help` creates a handout with full documentation.\\n\\n---\\n\\n## Primary Commands\\n\\n- `--set` — Update one or more properties across many pins at once.\\n- `--convert` — Extract data from tokens representing the same character and build or update a handout.\\n- `--place` — Create or replace pins based on handout headers, linking directly to those sections.\\n- `--purge` — Remove related tokens or pins in bulk.\\n- `--library` — Open the Pin Library to copy preset pin styles to selected pins.\\n- `--transform` — Apply transformations to pins, e.g., auto-generating icon text from titles.\\n- `--help` — Display the full PinTool help panel.\\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- Pin customization modes allow you to quickly switch the pin image between icons, text icons, or images.\\n\\nDesigned for GMs who want more automated control over pin placement, appearance, and management.", "authors": "Keith Curtis", "roll20userid": "162065", "dependencies": [], @@ -11,5 +11,5 @@ "pin": "write" }, "conflicts": [], - "previousversions": ["1.0.0","1.0.1"] + "previousversions": ["1.0.0","1.0.1","1.0.2"] } \ No newline at end of file From 1e3e51c95d919c1018db26c17f1dd96b7aa205b1 Mon Sep 17 00:00:00 2001 From: keithcurtis1 Date: Mon, 2 Mar 2026 22:45:39 -0800 Subject: [PATCH 6/9] Update PinTool.js --- PinTool/PinTool.js | 2391 ++++++++++++++------------------------------ 1 file changed, 769 insertions(+), 1622 deletions(-) diff --git a/PinTool/PinTool.js b/PinTool/PinTool.js index 7d521588e..f71169c18 100644 --- a/PinTool/PinTool.js +++ b/PinTool/PinTool.js @@ -1,29 +1,16 @@ // 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", () => { const version = '1.0.3'; //version number set here log('-=> PinTool v' + version + ' is loaded. Use !pintool --help for documentation.'); - //1.0.3 Huge update: Normalized headers with html entities, Added more transformation options on --set: math, and words for scale, Added advanced customization, pin style library, auto numbering + //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 @@ -36,9 +23,6 @@ on("ready", () => const scriptName = "PinTool"; const PINTOOL_HELP_NAME = "Help: PinTool"; const PINTOOL_HELP_AVATAR = "https://files.d20.io/images/470559564/QxDbBYEhr6jLMSpm0x42lg/original.png?1767857147"; - const ICON_SPRITE_URL = "https://files.d20.io/images/477999554/bETqvktx8A9TszRZBnmDWg/original.png?1772436951"; - const ICON_SIZE = 40; // original sprite slice size - const ICON_DISPLAY_SIZE = 20; // rendered size (50%) const PINTOOL_HELP_TEXT = `

    PinTool Script Help

    @@ -67,8 +51,6 @@ It also provides commands for conversion of old-style note tokens to new
  • --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.
  • -
  • --library — Browse and copy saved pin styles from the Pin Library page.
  • -
  • --transform — Apply transformations to pins (currently supports automatic text icon generation).

  • @@ -141,18 +123,6 @@ See examples at the end of this document.

    Appearance

    • scale — Range: 0.252.0
    • -
    • Preset sizes: teeny, tiny, small, medium, large, huge, gigantic
    • -
    • bgColor — Background color (hex rgb or rgba for transparency) or transparent)
    • -
    • shapeteardrop, circle, diamond, square
    • -
    • tooltipImageSizesmall, medium, large, xl
    • -
    • Display Mode
    • -
    • customizationTypeicon or image
    • -
    • icon — Icon preset identifier
    • -
    • pinImage — Roll20 image URL for custom pin image
    • -
    • useTextIcontrue or false
    • -
    • iconText — Up to 3 characters displayed as a text icon
    • -

      Note, setting icon, iconText, or pinImage will automatically change the customizationType to match.

      -

    State

    @@ -313,78 +283,6 @@ The purge command removes all tokens on the map similar to the tokens or pins
    - -
    - -

    Transform Command

    - -

    -The transform command applies derived transformations to pins. -

    - -

    Format:

    -
    -!pintool --transform autotext [filter|target]
    -
    - -

    Supported Transforms

    - -
      -
    • - autotext
      - Derives up to 3 characters from the pin’s title (or subLink if title is empty) - and converts the pin into a text icon. -
    • -
    - -

    -Text is derived from the first alphanumeric characters in the title. -If no valid characters are found, the pin is not modified. -

    - -
    - -

    Pin Library

    - -

    -The library command allows you to browse and copy saved pin styles -from a dedicated page named Pin Library. -

    - -

    Format:

    -
    -!pintool --library
    -!pintool --library keyword|keyword
    -
    - -

    Setup

    - -
      -
    • Create a page named exactly Pin Library.
    • -
    • Create pins on that page configured with the styles you want to reuse.
    • -
    • Add keywords to each pin title in square brackets:
    • -
    - -
    -Camp [travel, wilderness]
    -Battle [combat, viking]
    -Treasure [loot]
    -
    - -

    Behavior

    - -
      -
    • !pintool --library lists all available keywords.
    • -
    • Selecting a keyword displays matching pin styles.
    • -
    • Clicking a style copies its appearance to selected pins.
    • -
    • Position, title, notes, and links are not overwritten.
    • -
    - -

    -If the Pin Library page does not exist or contains no valid keyworded pins, -the command will display an error. -

    -

    Example Macros

    @@ -396,10 +294,6 @@ the command will display an error.
  • !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
  • -
  • !pintool --set bgColor|#307bb8 shape|circle
    Sets selected pin color and shape
  • -
  • !pintool --set pinImage|https://...
    Sets custom pin image
  • -
  • !pintool --transform autotext
    Generates 3-letter text icons from titles
  • -
  • !pintool --library
    Browse saved pin styles

  • @@ -413,57 +307,7 @@ the command will display an error. `; - const ICON_ORDER = [ - "base-dot", - "base-castle", - "base-skullSimple", - "base-spartanHelm", - "base-radioactive", - "base-heart", - "base-star", - "base-starSign", - "base-pin", - "base-speechBubble", - "base-file", - "base-plus", - "base-circleCross", - "base-dartBoard", - "base-badge", - "base-flagPin", - "base-crosshair", - "base-scrollOpen", - "base-diamond", - "base-photo", - "base-fourStarShort", - "base-circleStar", - "base-lock", - "base-crown", - "base-leaf", - "base-signpost", - "base-beer", - "base-compass", - "base-video", - "base-key", - "base-chest", - "base-village", - "base-swordUp", - "base-house", - "base-house2", - "base-church", - "base-government", - "base-blacksmith", - "base-stable", - "base-gear", - "base-bridge", - "base-mountain", - "base-exclamation", - "base-question" - ]; - - - - - let sender; +let sender; const getPageForPlayer = (playerid) => { @@ -516,294 +360,153 @@ the command will display an error. } - 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;", - - iconSpriteButton: "display:inline-block;" + - "width:40px;" + - "height:40px;" + - "background-color:#000;" + // force black behind transparent png - "background-repeat:no-repeat;" + - "background-size:1760px 40px;" + - "border:1px solid #555;" + - "border-radius:2px;" + - "margin:1px;" + - "padding:0;" + - "line-height:0;" + - "font-size:0;" + - "text-decoration:none;" + - "vertical-align:top;", - - panelButtonLeft: "display:inline-block;" + - "padding:2px 6px;" + - "border-radius:6px;" + - "background:#333;" + - "border:1px solid #555;" + - "border-right:none;" + - "color:#9fd3ff;" + - "text-decoration:none;" + - "font-size:12px;" + - "margin:0 2px 4px 0px;", - - 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;", - - colorButton: "display:inline-block;" + - "width:20px;" + - "height:20px;" + - "border:1px solid #555;" + - "border-radius:2px;" + - "margin:1px;" + - "padding:0;" + - "vertical-align:middle;" + - "text-decoration:none;", - - libraryPinButton: "display:block;" + - "margin:4px 0;" + - "padding:4px;" + - "border-radius:4px;" + - "background:#2a2a2a;" + - "border:1px solid #555;" + - "color:#fff;" + - "text-decoration:none;" + - "font-size:12px;" + - "white-space:nowrap;", - - libraryPinVisual: "display:inline-block;" + - "width:35px;" + - "height:35px;" + - "margin-right:6px;" + - "vertical-align:middle;" + - "border:1px solid #555;" + - "border-radius:4px;" + - "background-color:#000;", - - libraryPinText: "display:inline-block;" + - "vertical-align:middle;" - }; - } +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 splitButton(label, command) +{ + const css = getCSS(); - function iconSpriteButton(index, iconValue) - { - const offsetX = -(index * ICON_DISPLAY_SIZE); - - return ` -
    - - -
    - `; - } + return ( + `${label}` + + `++` + ); +} - function messageButton(label, command) - { - const css = getCSS(); +function messageButton(label, command) +{ + const css = getCSS(); - return ( - `${label}` - ); - } + return ( + `${label}` + ); +} - function showControlPanel() - { - const css = getCSS(); +function showControlPanel() +{ + const css = getCSS(); - const colors = [ - "#242424", "#307bb8", "#721211", "#e59a00", "#b40f69", "#2d0075", "#e26608", "#588b02", "#bb1804", - "#ffffff", "#000000" - ]; - - const colorButtons = colors.map((c, i) => - (i === colors.length - 2 ? "
    " : "") + - `` - ).join(''); - - const panel = - `
    ` + - - // SIZE - `
    Size
    ` + - splitButton("Teeny", "!pintool --set scale|teeny") + - splitButton("Tiny", "!pintool --set scale|tiny") + - splitButton("Small", "!pintool --set scale|small") + - splitButton("Medium", "!pintool --set scale|medium") + - `
    ` + - splitButton("Large", "!pintool --set scale|large") + - splitButton("Huge", "!pintool --set scale|huge") + - splitButton("Gigantic", "!pintool --set scale|gigantic") + - `
    ` + - - // VISIBILITY - `
    Visible
    ` + + 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") + - `
    ` + + splitButton("All Players", "!pintool --set visibleTo|all") + + `
    ` + - // BLOCKQUOTE - `
    Blockquote as player text
    ` + - splitButton("On", "!pintool --set autoNotesType|blockquote") + + `
    Blockquote as player text
    ` + + splitButton("On", "!pintool --set autoNotesType|blockquote") + splitButton("Off", "!pintool --set autoNotesType|") + - `
    ` + + `
    ` + - // DISPLAY SYNC - `
    Display
    ` + - splitButton("From Handout", "!pintool --set imageDesynced|false imageVisibleTo|") + + `
    Display
    ` + + splitButton("From Handout", "!pintool --set imageDesynced|false imageVisibleTo|") + splitButton("Custom", "!pintool --set imageDesynced|true imageVisibleTo|all") + - `
    ` + - - // CUSTOMIZATION MODE - `
    Customization Mode
    ` + - splitButton("Icon", "!pintool --set customizationType|icon") + - splitButton("Image", "!pintool --set customizationType|image") + - splitButton("Text", "!pintool --set useTextIcon|true") + - splitButton("Set Text", "!pintool --set iconText|?{Input up to 3 characters}") + `
    ` + - splitButton("Pin Text from Title", "!pintool --transform autotext") + - splitButton("Hide Names", "!pintool --set nameplateVisibleTo|") + - - - `
    ` + - - - // ICON QUICK PICKS - `
    Icon Presets
    ` + - ICON_ORDER.map((icon, i) => iconSpriteButton(i, icon)).join("") + - `
    ` + - - // PIN IMAGE - `
    Pin Image
    ` + - splitButton("Set Pin Image", "!pintool --set pinImage|?{Roll20 Image URL}") + - splitButton("Clear Image", "!pintool --set pinImage| customizationType|icon") + - `
    ` + - - // TOOLTIP IMAGE - `
    Tooltip Image
    ` + - splitButton("Set Tooltip Image", "!pintool --set tooltipImage|?{Roll20 Image URL}") + - splitButton("S", "!pintool --set tooltipImageSize|small") + - splitButton("M", "!pintool --set tooltipImageSize|medium") + - splitButton("L", "!pintool --set tooltipImageSize|large") + - splitButton("XL", "!pintool --set tooltipImageSize|xl") + - `
    ` + - - // SHAPE - `
    Shape
    ` + - splitButton("Teardrop", "!pintool --set shape|teardrop") + - splitButton("Circle", "!pintool --set shape|circle") + - splitButton("Diamond", "!pintool --set shape|diamond") + - splitButton("Square", "!pintool --set shape|square") + - `
    ` + - - // BACKGROUND COLOR - `
    Pin Colors
    ` + - colorButtons + - splitButton("Transparent", "!pintool --set bgColor|") + - splitButton("Custom Color", "!pintool --set bgColor|?{Enter custom color (hex or transparent)}") + - `
    ` + - - // Pin LIbrary - `
    Pin Library ` + - splitButton("See Styles", "!pintool --library") + - `
    ` + - - // SCALE PLACEMENT - `
    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 FROM HANDOUT - `
    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}") + - `
    ` + - - `
    `; + `
    ` + - sendStyledMessage( - "PinTool Control Panel", - panel - ); - } + `
    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) @@ -875,23 +578,19 @@ the command will display an error. 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)}".` - ); - } + 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(); + burndown(); } return; @@ -1049,305 +748,48 @@ the command will display an error. sendStyledMessage("PinTool — Warning", msg); } - -//Pin library functions - - function parseLibraryTitle(title) - { - const match = title.match(/\[(.*?)\]/); - if(!match) return null; - - const keywordBlock = match[1]; - const keywords = keywordBlock - .split(',') - .map(k => k.trim().toLowerCase()) - .filter(k => k.length); - - const cleanTitle = title.replace(/\s*\[.*?\]\s*/, '').trim(); - - return { - cleanTitle, - keywords - }; - } - - function getLibraryPage() - { - return findObjs( - { - _type: "page", - name: "Pin Library" - })[0]; - } - - -function showLibraryKeywords() -{ - const css = getCSS(); - const page = getLibraryPage(); - - if(!page) { - sendError("Pin Library page not found. Create a page named 'Pin Library' and add pins with keywords. See !pintool --help for details."); - return; - } - - const pins = findObjs( - { - _type: "pin", - _pageid: page.id - }); - - const keywordSet = new Set(); - - pins.forEach(pin => - { - const parsed = parseLibraryTitle(pin.get("title")); - if(!parsed) return; - - parsed.keywords.forEach(k => keywordSet.add(k)); - }); - - const keywords = Array.from(keywordSet).sort(); - - if(keywords.length === 0) { - sendError("No pins with keywords found on the Pin Library page. See !pintool --help to create them."); - return; - } - - const buttons = keywords.map(k => - `${k}` - ).join("
    "); - - const output = - `
    -
    Pin Library
    -
    - ${buttons} -
    -
    -${messageButton("Main Menu", "!pintool")} -
    -
    `; - - sendChat("PinTool", `/w gm ${output}`); -} - - -function buildLibraryPinButton(pin) { - const css = getCSS(); - const title = pin.get("title"); - const parsed = parseLibraryTitle(title); - if (!parsed) return ""; - - const cleanTitle = parsed.cleanTitle; - - const useTextIcon = pin.get("useTextIcon"); - const customizationType = pin.get("customizationType"); - const pinImage = pin.get("pinImage"); - const icon = pin.get("icon"); - const bgColor = pin.get("bgColor") || "#000"; - const iconText = pin.get("iconText"); - - let visual = ""; - - // Base styles for the visual div - const baseStyle = ` - width:35px; - height:35px; - display:inline-block; - vertical-align:middle; - border-radius:4px; - text-align:center; - line-height:35px; - font-weight:bold; - overflow:hidden; - background-size: auto 100%; - `; - - if (useTextIcon === true && iconText) { - // Text Icon - visual = `
    ${iconText.substring(0,3)}
    `; - } - else if (customizationType === "image" && pinImage) { - // Image pin — always light neutral gray behind - const grayBg = "#ccc"; - visual = `
    -
    `; - } -else if (customizationType === "icon" && icon) { - const iconIndex = ICON_ORDER.indexOf(icon); - const totalIcons = ICON_ORDER.length; - const bgPosPercent = (iconIndex / (totalIcons - 1)) * 100; - - visual = `
    -
    `; -} - else { - // Only color - visual = `
    `; - } - - return ` - ${visual} - ${cleanTitle} - `; -} - - - function showLibraryKeywordResults(keyword) - { - const css = getCSS(); - const page = getLibraryPage(); - if(!page) return; - - const lower = keyword.toLowerCase(); - - const pins = findObjs( - { - _type: "pin", - _pageid: page.id - }); - - const matches = pins.filter(pin => - { - const parsed = parseLibraryTitle(pin.get("title")); - if(!parsed) return false; - return parsed.keywords.includes(lower); - }); - - matches.sort((a, b) => - { - const pa = parseLibraryTitle(a.get("title")); - const pb = parseLibraryTitle(b.get("title")); - return pa.cleanTitle.localeCompare(pb.cleanTitle); - }); - - const buttons = matches.map(buildLibraryPinButton).join(""); - - const output = - `
    -
    Keyword: ${keyword}
    -
    - ${buttons} -
    -
    -${splitButton("Change Keyword", "!pintool --library")} - ${splitButton("Main Menu", "!pintool")} -
    -
    `; - - sendChat("PinTool", `/w gm ${output}`); - } - - - function copyLibraryPinToSelection(pinId, selected) - { - const libraryPin = getObj("pin", pinId); - if(!libraryPin) return; - - const targets = (selected || []) - .map(s => getObj(s._type, s._id)) - .filter(o => o && o.get("_type") === "pin"); - - if(!targets.length) - { - sendChat("PinTool", `/w gm No pins selected.`); - return; - } - - const props = libraryPin.attributes; - - targets.forEach(target => - { - Object.keys(props).forEach(key => - { - if([ - "title", - "link", - "linkType", - "subLink", - "subLinkType", - "_id", - "_type", - "x", - "y", - "notes", - "gmNotes", - "y", - "y", - "_pageid" - ].includes(key)) return; - - target.set(key, props[key]); - }); - }); - } - - - - - - - - - // ============================================================ // 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 isValidRoll20Image = (url) => { + return typeof url === 'string' && url.includes('files.d20.io/images'); +}; - const isRoll20Image = isValidRoll20Image(url); - let buttons = - `` + - `Send to All`; +function handleImageToChat(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 isRoll20Image = isValidRoll20Image(url); - const imageHtml = - `
    ` + - `` + - `
    ${buttons}
    ` + - `
    `; + let buttons = + `` + + `Send to All`; - sendChat( - "PinTool", - `/w "${sender}" ${imageHtml}`, - null, - { - noarchive: true - } - ); + if (isRoll20Image) { + buttons += + ` ` + + `Place image in Pin`; } + const imageHtml = + `
    ` + + `` + + `
    ${buttons}
    ` + + `
    `; + + sendChat( + "PinTool", + `/w "${sender}" ${imageHtml}`, + null, + { noarchive: true } + ); +} + function handleImageToChatAll(encodedUrl) @@ -1356,26 +798,24 @@ ${splitButton("Change Keyword", "!pintool --library")} if(!/^https?:\/\//i.test(url)) return sendError("Invalid image URL."); sendChat( - "PinTool", `
    `, - null, - { - noarchive: true - }); + "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 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 = { @@ -1383,15 +823,12 @@ ${splitButton("Change Keyword", "!pintool --library")} y: "number", title: "string", notes: "string", - - tooltipImage: "roll20image", - pinImage: "roll20image", - + image: "string", + tooltipImage: "string", link: "string", linkType: ["", "handout"], subLink: "string", subLinkType: ["", "headerPlayer", "headerGM"], - visibleTo: ["", "all"], tooltipVisibleTo: ["", "all"], nameplateVisibleTo: ["", "all"], @@ -1399,42 +836,16 @@ ${splitButton("Change Keyword", "!pintool --library")} notesVisibleTo: ["", "all"], gmNotesVisibleTo: ["", "all"], autoNotesType: ["", "blockquote"], - scale: { min: 0.25, max: 2.0 }, - imageDesynced: "boolean", notesDesynced: "boolean", - gmNotesDesynced: "boolean", - - bgColor: "color", - shape: ["teardrop", "circle", "diamond", "square"], - - customizationType: ["icon", "image"], - icon: [ - "base-dot", "base-castle", "base-skullSimple", "base-spartanHelm", - "base-radioactive", "base-heart", "base-star", "base-starSign", - "base-pin", "base-speechBubble", "base-file", "base-plus", - "base-circleCross", "base-dartBoard", "base-badge", "base-flagPin", - "base-crosshair", "base-scrollOpen", "base-diamond", "base-photo", - "base-fourStarShort", "base-circleStar", "base-lock", "base-crown", - "base-leaf", "base-signpost", "base-beer", "base-compass", "base-video", - "base-key", "base-chest", "base-village", "base-swordUp", "base-house", - "base-house2", "base-church", "base-government", "base-blacksmith", - "base-stable", "base-gear", "base-bridge", "base-mountain", - "base-exclamation", "base-question" - ], - - useTextIcon: "boolean", - iconText: "string", - - tooltipImageSize: ["small", "medium", "large", "xl"] + gmNotesDesynced: "boolean" }; - function handleSet(msg, tokens) { const flags = {}; @@ -1515,327 +926,132 @@ ${splitButton("Change Keyword", "!pintool --library")} if(!pins.length) return sendWarning("Filter matched no pins on the current page."); - try +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 queue = pins.map(p => p.id); - const BATCH_SIZE = 10; + const p = getObj("pin", id); + if(!p) return; + + const updates = {}; - const processBatch = () => + Object.entries(flags).forEach(([key, raw]) => { - const slice = queue.splice(0, BATCH_SIZE); + const spec = PIN_SET_PROPERTIES[key]; + let value = raw; - slice.forEach(id => + // Boolean + if(spec === "boolean") { - const p = getObj("pin", id); - if(!p) return; - - const updates = {}; + value = raw === "true"; + } - const originalCustomization = p.get("customizationType") || "icon"; - let newCustomization = originalCustomization; - let revertingFromText = false; + // Simple numeric (x, y) + else if(spec === "number") + { + const current = Number(p.get(key)); + const opMatch = raw.match(/^([+\-*/])\s*(-?\d*\.?\d+)$/); - Object.entries(flags).forEach(([key, raw]) => + if(opMatch) { - const spec = PIN_SET_PROPERTIES[key]; - let value = raw; - - // Boolean - if(spec === "boolean") + 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 === "/") { - value = raw === "true"; + if(operand === 0) throw 0; + value = current / operand; } + } + else + { + value = Number(raw); + if(isNaN(value)) throw 0; + } + } - // Roll20 image validation - else if(spec === "roll20image") - { - if(value && !isValidRoll20Image(value)) throw 0; - } + // Enumerated + else if(Array.isArray(spec)) + { + if(!spec.includes(value)) throw 0; + } - // Color validation - else if(spec === "color") - { - if(!/^(transparent|#[0-9a-fA-F]{6}([0-9a-fA-F]{2})?)$/.test(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; - // Simple numeric - else if(spec === "number") - { - const current = Number(p.get(key)); - const opMatch = raw.match(/^([+\-*/])\s*(-?\d*\.?\d+)$/); + 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 + 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 === "/") { - value = Number(raw); - if(isNaN(value)) throw 0; + if(operand === 0) throw 0; + value = current / operand; } } - - // Enumerated - else if(Array.isArray(spec)) + else { - if(!spec.includes(value)) throw 0; + value = Number(raw); + if(isNaN(value)) throw 0; } + } - // Bounded numeric - else if(typeof spec === "object") - { - const current = Number(p.get(key)); - const lower = spec.min; - const upper = spec.max; + value = Math.max(lower, Math.min(upper, value)); + } - 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)); - } - - // ---- Behavioral Rules ---- - - if(key === "pinImage") - { - if(value) - newCustomization = "image"; - } - -if(key === "icon") -{ - newCustomization = "icon"; - updates.useTextIcon = false; -} - - if(key === "iconText") - { - if(!value) - { - const title = updates.title ?? p.get("title") ?? ""; - value = title.substring(0, 3); - } - else - { - value = value.substring(0, 3); - } - - updates.useTextIcon = true; - } - -if(key === "useTextIcon") -{ - if(value === true) - { - newCustomization = "icon"; // text icons are a variation of icon mode - } - else - { - revertingFromText = true; - } -} - -if(key === "customizationType") -{ - newCustomization = value; - - if(value === "icon") - updates.useTextIcon = false; -} - - updates[key] = value; - }); - - // Final mode resolution (last flag wins) - if(revertingFromText) - { - updates.customizationType = originalCustomization; - } - else - { - updates.customizationType = newCustomization; - } - - // Prevent empty image mode - if(updates.customizationType === "image") - { - const finalImage = updates.pinImage ?? p.get("pinImage"); - if(!finalImage) - updates.customizationType = "icon"; - } - - 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).`); - } - -function deriveAutoText(str) -{ - if(!str) return ""; - - const startMatch = str.match(/[A-Za-z0-9]/); - if(!startMatch) return ""; - - const sliced = str.slice(startMatch.index); - - const tokenMatch = sliced.match(/^[A-Za-z0-9]+/); - if(!tokenMatch) return ""; - - const token = tokenMatch[0]; - - return token.length <= 3 - ? token - : token.substring(0, 3); -} - -function handleTransform(msg, argString) -{ - if(!argString) - return sendError("No transform specified."); - - const tokens = argString.split(/\s+/); - const transformType = tokens[0].toLowerCase(); - - if(transformType !== "autotext") - return sendError(`Unknown transform: ${transformType}`); - - // ---- Parse filter ---- - - let filterRaw = ""; - - const filterMatch = argString.match(/filter\|(.+)/i); - if(filterMatch) - filterRaw = filterMatch[1].trim(); - - const pageId = getPageForPlayer(msg.playerid); - - 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("Transform matched no pins on the current page."); - - 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 title = p.get("title") || ""; - const subLink = p.get("subLink") || ""; - - const source = title.trim() ? title : subLink; - const derived = deriveAutoText(source); - - if(!derived) return; - - p.set({ - customizationType: "icon", - useTextIcon: true, - iconText: derived + updates[key] = value; }); - // force refresh + 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) @@ -1853,609 +1069,585 @@ function handleTransform(msg, argString) // CONVERT MODE // ============================================================ - function handleConvert(msg, tokens) - { + function handleConvert(msg, tokens) + { - if(!tokens.length) - { - sendConvertHelp(); - return; - } + if(!tokens.length) + { + sendConvertHelp(); + return; + } - // ---------------- Parse convert specs (greedy tail preserved) ---------------- - const flags = {}; - const orderedSpecs = []; + // ---------------- Parse convert specs (greedy tail preserved) ---------------- + 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; + + 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( { - const t = tokens[i]; - const idx = t.indexOf("|"); - if(idx === -1) continue; + key, + val + }); + i = j - 1; + } - const key = t.slice(0, idx).toLowerCase(); - let val = t.slice(idx + 1); + // ---------------- Required args ---------------- + if(!flags.title) return sendError("--convert requires title|"); + if(!flags.name) return sendError("--convert requires name|h1–h5"); - const parts = [val]; - let j = i + 1; + const nameMatch = flags.name.match(/^h([1-5])$/i); + if(!nameMatch) return sendError("name must be h1 through h5"); - while(j < tokens.length) - { - const next = tokens[j]; - if(next.indexOf("|") !== -1) break; - parts.push(next); - j++; - } + const nameHeaderLevel = parseInt(nameMatch[1], 10); + const minAllowedHeader = Math.min(nameHeaderLevel + 1, 6); - val = parts.join(" "); - flags[key] = val; - orderedSpecs.push( - { - key, - val - }); - i = j - 1; - } + const supernotes = flags.supernotesgmtext === "true"; + const imagelinks = flags.imagelinks === "true"; + const replace = flags.replace === "true"; // NEW - // ---------------- Required args ---------------- - if(!flags.title) return sendError("--convert requires title|"); - if(!flags.name) return sendError("--convert requires name|h1–h5"); + // ---------------- Token validation ---------------- + if(!msg.selected || !msg.selected.length) + { + sendError("Please select a token."); + return; + } - const nameMatch = flags.name.match(/^h([1-5])$/i); - if(!nameMatch) return sendError("name must be h1 through h5"); + const selectedToken = getObj("graphic", msg.selected[0]._id); + if(!selectedToken) return sendError("Invalid token selection."); - const nameHeaderLevel = parseInt(nameMatch[1], 10); - const minAllowedHeader = Math.min(nameHeaderLevel + 1, 6); + 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; + } - const supernotes = flags.supernotesgmtext === "true"; - const imagelinks = flags.imagelinks === "true"; - const replace = flags.replace === "true"; // NEW + // ---------------- Helpers ---------------- + const decodeUnicode = str => + str.replace(/%u[0-9A-Fa-f]{4}/g, m => + String.fromCharCode(parseInt(m.slice(2), 16)) + ); - // ---------------- Token validation ---------------- - if(!msg.selected || !msg.selected.length) + function decodeNotes(raw) + { + if(!raw) return ""; + let s = decodeUnicode(raw); + try + { + s = decodeURIComponent(s); + } + catch + { + try { - sendError("Please select a token."); - return; + s = unescape(s); } - - 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( + catch (e) { - _type: "graphic", - _subtype: "token", - _pageid: pageId, - represents: charId - }); - - if(!tokensOnPage.length) - { - sendError("No matching map tokens found."); - return; + log(e); } + } + return s.replace(/^]*>/i, "").replace(/<\/div>$/i, "").trim(); + } - // ---------------- 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 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}
    `; - function applyBlockquoteSplit(html) - { - const blocks = html.match(//gi); - if(!blocks) return `
    ${html}
    `; + const idx = blocks.findIndex( + b => normalizeVisibleText(b) === "-----" + ); - const idx = blocks.findIndex( - b => normalizeVisibleText(b) === "-----" - ); + // NEW: no separator → everything is player-visible + if(idx === -1) + { + return `
    ${blocks.join("")}
    `; + } - // 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(""); - // Separator exists → split as before - const player = blocks.slice(0, idx).join(""); - const gm = blocks.slice(idx + 1).join(""); + return `
    ${player}
    \n${gm}`; + } - 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 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 encodeProtocol(url) + 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) { - return url.replace(/^(https?):\/\//i, "$1!!!"); + out += `
    [Image]`; } + return out; + } + ); + + if(imagelinks) + { + html = html.replace( + /(]*\bsrc=["']([^"']+)["'][^>]*>)(?![\s\S]*?\[Image\])/gi, + (m, img, url) => + `${img}
    [Image]` + ); + } - 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; - } - ); + return html; + } - if(imagelinks) - { - html = html.replace( - /(]*\bsrc=["']([^"']+)["'][^>]*>)(?![\s\S]*?\[Image\])/gi, - (m, img, url) => - `${img}
    [Image]` - ); - } + 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; + } - return html; - } + // ---------------- Build output ---------------- + const output = []; + const tokenByName = {}; // NEW: exact name → token + const pinsToCreateCache = new Set(); - function applyFormat(content, format) + let workTokensOnPage = tokensOnPage + .sort((a, b) => (a.get("name") || "").localeCompare(b.get("name") || "", undefined, { - 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; - } - + 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; - // ---------------- Build output ---------------- - const output = []; - const tokenByName = {}; // NEW: exact name → token - const pinsToCreateCache = new Set(); + const skipped = []; +// const headerRegex = new RegExp(`([\\s\\S]*?)<\\/h${nameHeaderLevel}>`, "gi"); + + const headers = [...pinsToCreateCache]; - let workTokensOnPage = tokensOnPage - .sort((a, b) => (a.get("name") || "").localeCompare(b.get("name") || "", undefined, - { - sensitivity: "base" - })); + const replaceBurndown = () => { + let header = headers.shift(); + if( header ) { + const headerText = _.unescape(header).trim(); + const token = tokenByName[headerText]; + if(!token) + { + skipped.push(headerText); + return; + } - const finishUp = () => - { - // ---------------- Handout creation ---------------- - let h = findObjs( + const existingPin = findObjs( { - _type: "handout", - name: flags.title + _type: "pin", + _pageid: pageId, + link: handoutId, + subLink: headerText })[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]; + 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 + }); - const replaceBurndown = () => + if(pin) { - let header = headers.shift(); - if(header) + pin.set( { - 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]; - + link: handoutId, + linkType: "handout", + subLink: headerText + }); + } + } + setTimeout(replaceBurndown,0); + } else { - if(existingPin) - { - existingPin.set( - { - x: token.get("left"), - y: token.get("top"), - link: handoutId, - linkType: "handout", - subLink: headerText - }); + if(skipped.length) + { + sendStyledMessage( + "Convert: Pins Skipped", + `
      ${skipped.map(s => `
    • ${_.escape(s)}
    • `).join("")}
    ` + ); + } else { + sendStyledMessage( + "Finished Adding Pins", + `Created ${pinsToCreateCache.size} Map Pins.` + ); + } + } + }; + replaceBurndown(); + }; - } - else - { - // Two-step pin creation to avoid desync errors - const pin = + const burndown = ()=>{ + let token = workTokensOnPage.shift(); + if(token) { + const tokenName = token.get("name") || ""; + tokenByName[tokenName] = token; // exact string match - 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 - { + output.push(`${_.escape(tokenName)}`); + pinsToCreateCache.add(_.escape(tokenName)); - if(skipped.length) - { - sendStyledMessage( - "Convert: Pins Skipped", - `
      ${skipped.map(s => `
    • ${_.escape(s)}
    • `).join("")}
    ` - ); - } - else - { - sendStyledMessage( - "Finished Adding Pins", - `Created ${pinsToCreateCache.size} Map Pins.` - ); - } - } - }; - replaceBurndown(); - }; + orderedSpecs.forEach(spec => + { + if(["name", "title", "supernotesgmtext", "imagelinks", "replace"].includes(spec.key)) return; - const burndown = () => - { - let token = workTokensOnPage.shift(); - if(token) + let value = ""; + if(spec.key === "gmnotes") { - 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); + value = decodeNotes(token.get("gmnotes") || ""); + if(supernotes) value = applyBlockquoteSplit(value); + value = downgradeHeaders(value); + value = convertImages(value); } - else + else if(spec.key === "tooltip") { - finishUp(); + value = token.get("tooltip") || ""; + } + else if(/^bar[1-3]_(value|max)$/.test(spec.key)) + { + value = token.get(spec.key) || ""; } - }; - burndown(); + if(value) output.push(applyFormat(value, spec.val)); + }); + setTimeout(burndown,0); + } else { + finishUp(); + } + }; - } + burndown(); + + } // ============================================================ // PLACE MODE // ============================================================ - function handlePlace(msg, args) - { + function handlePlace(msg, args) + { - if(!args.length) return; + if(!args.length) return; - /* ---------------- Parse args ---------------- */ - const flags = {}; + /* ---------------- Parse args ---------------- */ + const flags = {}; - for(let i = 0; i < args.length; i++) - { - const t = args[i]; - const idx = t.indexOf("|"); - if(idx === -1) continue; + 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 key = t.slice(0, idx).toLowerCase(); + let val = t.slice(idx + 1); - const parts = [val]; - let j = i + 1; + const parts = [val]; + let j = i + 1; - while(j < args.length && args[j].indexOf("|") === -1) - { - parts.push(args[j]); - j++; - } + while(j < args.length && args[j].indexOf("|") === -1) + { + parts.push(args[j]); + j++; + } - flags[key] = parts.join(" "); - i = j - 1; - } + 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|"); + 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 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; + 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.`); + /* ---------------- 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; + const handout = handouts[0]; + const handoutId = handout.id; - /* ---------------- Page ---------------- */ - const pageId = getPageForPlayer(msg.playerid); + /* ---------------- Page ---------------- */ + const pageId = getPageForPlayer(msg.playerid); - if(typeof pageId === "undefined") - return sendError("pageId is not defined."); + if(typeof pageId === "undefined") + return sendError("pageId is not defined."); - const page = getObj("page", pageId); - if(!page) return sendError("Invalid pageId."); + 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 gridSize = page.get("snapping_increment") * 70 || 70; + const maxCols = Math.floor((page.get("width") * 70) / gridSize); - const startX = gridSize / 2; - const startY = gridSize / 2; + const startX = gridSize / 2; + const startY = gridSize / 2; - let col = 0; - let row = 0; + let col = 0; + let row = 0; - /* ---------------- Header extraction ---------------- */ - const headerRegex = new RegExp( - `([\\s\\S]*?)<\\/h${headerLevel}>`, - "gi" - ); + /* ---------------- Header extraction ---------------- */ + const headerRegex = new RegExp( + `([\\s\\S]*?)<\\/h${headerLevel}>`, + "gi" + ); - const headers = []; // { text, subLinkType } + 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 - }); - } - } +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")); + handout.get("notes", html => extractHeaders(html, "headerPlayer")); + handout.get("gmnotes", html => extractHeaders(html, "headerGM")); - if(!headers.length) - return sendError(`No headers found in handout.`); + if(!headers.length) + return sendError(`No headers found in handout.`); - /* ---------------- Existing pins ---------------- */ - const existingPins = findObjs( - { - _type: "pin", - _pageid: pageId, - link: handoutId - }); + /* ---------------- 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; - }); + const pinByKey = {}; + existingPins.forEach(p => + { + const key = `${p.get("subLink")}||${p.get("subLinkType") || ""}`; + pinByKey[key] = p; + }); - let created = 0; - let replaced = 0; + let created = 0; + let replaced = 0; - /* ---------------- Placement ---------------- */ - const burndown = () => - { - let h = headers.shift(); - if(h) - { + /* ---------------- Placement ---------------- */ + const burndown = () => { + let h = headers.shift(); + if(h) { - const headerText = h.text; - const subLinkType = h.subLinkType; - const key = `${headerText}||${subLinkType}`; + const headerText = h.text; + const subLinkType = h.subLinkType; + const key = `${headerText}||${subLinkType}`; - let x, y; - const existing = pinByKey[key]; + 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; + 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); + // 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++; - } + 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 + // Two-step creation (same defaults as convert) + createObj("pin", { - /* ---------------- Report ---------------- */ - sendStyledMessage( - "Place Pins", - `

    Handout: ${_.escape(handoutName)}

    + 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(); + ); + } + }; + burndown(); + + } - } @@ -2467,73 +1659,28 @@ function handleTransform(msg, argString) on("chat:message", msg => { if(msg.type !== "api" || !/^!pintool\b/i.test(msg.content)) return; - sender = msg.who.replace(/\s\(GM\)$/, ''); +const parts = msg.content.trim().split(/\s+/); +const cmd = parts[1]?.toLowerCase(); - const parts = msg.content.trim().split(/\s+/); - const cmd = parts[1]?.toLowerCase(); - - if(parts.length === 1) - { - showControlPanel(); - return; - } +if(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 === "--library") -{ - // Rebuild everything after --library, preserving spaces - const argString = msg.content - .replace(/^!pintool\s+--library\s*/i, "") - .trim(); - - if(!argString) - return showLibraryKeywords(); - - if(argString.startsWith("keyword|")) - return showLibraryKeywordResults(argString.slice(8)); - - if(argString.startsWith("copy|")) - return copyLibraryPinToSelection(argString.slice(5), msg.selected); - - return sendError("Invalid --library syntax."); -} - - if(cmd?.startsWith("--imagetochat|")) return handleImageToChat(parts[1].slice(14)); - if(cmd?.startsWith("--imagetochatall|")) return handleImageToChatAll(parts[1].slice(17)); - - - if(cmd === "--transform") - { - const argString = msg.content - .replace(/^!pintool\s+--transform\s*/i, "") - .trim(); - return handleTransform(msg, argString); - } 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); - } -} \ No newline at end of file +{try{throw new Error('');}catch(e){API_Meta.PinTool.lineCount=(parseInt(e.stack.split(/\n/)[1].replace(/^.*:(\d+):.*$/,'$1'),10)-API_Meta.PinTool.offset);}} From db78ee51f92c4b2286a9c1be16e78483ec711465 Mon Sep 17 00:00:00 2001 From: keithcurtis1 Date: Mon, 2 Mar 2026 22:46:29 -0800 Subject: [PATCH 7/9] Update print statement from 'Hello' to 'Goodbye' --- PinTool/1.0.3/PinTool.js | 2391 ++++++++++++-------------------------- 1 file changed, 769 insertions(+), 1622 deletions(-) diff --git a/PinTool/1.0.3/PinTool.js b/PinTool/1.0.3/PinTool.js index 7d521588e..f71169c18 100644 --- a/PinTool/1.0.3/PinTool.js +++ b/PinTool/1.0.3/PinTool.js @@ -1,29 +1,16 @@ // 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", () => { const version = '1.0.3'; //version number set here log('-=> PinTool v' + version + ' is loaded. Use !pintool --help for documentation.'); - //1.0.3 Huge update: Normalized headers with html entities, Added more transformation options on --set: math, and words for scale, Added advanced customization, pin style library, auto numbering + //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 @@ -36,9 +23,6 @@ on("ready", () => const scriptName = "PinTool"; const PINTOOL_HELP_NAME = "Help: PinTool"; const PINTOOL_HELP_AVATAR = "https://files.d20.io/images/470559564/QxDbBYEhr6jLMSpm0x42lg/original.png?1767857147"; - const ICON_SPRITE_URL = "https://files.d20.io/images/477999554/bETqvktx8A9TszRZBnmDWg/original.png?1772436951"; - const ICON_SIZE = 40; // original sprite slice size - const ICON_DISPLAY_SIZE = 20; // rendered size (50%) const PINTOOL_HELP_TEXT = `

    PinTool Script Help

    @@ -67,8 +51,6 @@ It also provides commands for conversion of old-style note tokens to new
  • --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.
  • -
  • --library — Browse and copy saved pin styles from the Pin Library page.
  • -
  • --transform — Apply transformations to pins (currently supports automatic text icon generation).

  • @@ -141,18 +123,6 @@ See examples at the end of this document.

    Appearance

    • scale — Range: 0.252.0
    • -
    • Preset sizes: teeny, tiny, small, medium, large, huge, gigantic
    • -
    • bgColor — Background color (hex rgb or rgba for transparency) or transparent)
    • -
    • shapeteardrop, circle, diamond, square
    • -
    • tooltipImageSizesmall, medium, large, xl
    • -
    • Display Mode
    • -
    • customizationTypeicon or image
    • -
    • icon — Icon preset identifier
    • -
    • pinImage — Roll20 image URL for custom pin image
    • -
    • useTextIcontrue or false
    • -
    • iconText — Up to 3 characters displayed as a text icon
    • -

      Note, setting icon, iconText, or pinImage will automatically change the customizationType to match.

      -

    State

    @@ -313,78 +283,6 @@ The purge command removes all tokens on the map similar to the tokens or pins
    - -
    - -

    Transform Command

    - -

    -The transform command applies derived transformations to pins. -

    - -

    Format:

    -
    -!pintool --transform autotext [filter|target]
    -
    - -

    Supported Transforms

    - -
      -
    • - autotext
      - Derives up to 3 characters from the pin’s title (or subLink if title is empty) - and converts the pin into a text icon. -
    • -
    - -

    -Text is derived from the first alphanumeric characters in the title. -If no valid characters are found, the pin is not modified. -

    - -
    - -

    Pin Library

    - -

    -The library command allows you to browse and copy saved pin styles -from a dedicated page named Pin Library. -

    - -

    Format:

    -
    -!pintool --library
    -!pintool --library keyword|keyword
    -
    - -

    Setup

    - -
      -
    • Create a page named exactly Pin Library.
    • -
    • Create pins on that page configured with the styles you want to reuse.
    • -
    • Add keywords to each pin title in square brackets:
    • -
    - -
    -Camp [travel, wilderness]
    -Battle [combat, viking]
    -Treasure [loot]
    -
    - -

    Behavior

    - -
      -
    • !pintool --library lists all available keywords.
    • -
    • Selecting a keyword displays matching pin styles.
    • -
    • Clicking a style copies its appearance to selected pins.
    • -
    • Position, title, notes, and links are not overwritten.
    • -
    - -

    -If the Pin Library page does not exist or contains no valid keyworded pins, -the command will display an error. -

    -

    Example Macros

    @@ -396,10 +294,6 @@ the command will display an error.
  • !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
  • -
  • !pintool --set bgColor|#307bb8 shape|circle
    Sets selected pin color and shape
  • -
  • !pintool --set pinImage|https://...
    Sets custom pin image
  • -
  • !pintool --transform autotext
    Generates 3-letter text icons from titles
  • -
  • !pintool --library
    Browse saved pin styles

  • @@ -413,57 +307,7 @@ the command will display an error. `; - const ICON_ORDER = [ - "base-dot", - "base-castle", - "base-skullSimple", - "base-spartanHelm", - "base-radioactive", - "base-heart", - "base-star", - "base-starSign", - "base-pin", - "base-speechBubble", - "base-file", - "base-plus", - "base-circleCross", - "base-dartBoard", - "base-badge", - "base-flagPin", - "base-crosshair", - "base-scrollOpen", - "base-diamond", - "base-photo", - "base-fourStarShort", - "base-circleStar", - "base-lock", - "base-crown", - "base-leaf", - "base-signpost", - "base-beer", - "base-compass", - "base-video", - "base-key", - "base-chest", - "base-village", - "base-swordUp", - "base-house", - "base-house2", - "base-church", - "base-government", - "base-blacksmith", - "base-stable", - "base-gear", - "base-bridge", - "base-mountain", - "base-exclamation", - "base-question" - ]; - - - - - let sender; +let sender; const getPageForPlayer = (playerid) => { @@ -516,294 +360,153 @@ the command will display an error. } - 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;", - - iconSpriteButton: "display:inline-block;" + - "width:40px;" + - "height:40px;" + - "background-color:#000;" + // force black behind transparent png - "background-repeat:no-repeat;" + - "background-size:1760px 40px;" + - "border:1px solid #555;" + - "border-radius:2px;" + - "margin:1px;" + - "padding:0;" + - "line-height:0;" + - "font-size:0;" + - "text-decoration:none;" + - "vertical-align:top;", - - panelButtonLeft: "display:inline-block;" + - "padding:2px 6px;" + - "border-radius:6px;" + - "background:#333;" + - "border:1px solid #555;" + - "border-right:none;" + - "color:#9fd3ff;" + - "text-decoration:none;" + - "font-size:12px;" + - "margin:0 2px 4px 0px;", - - 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;", - - colorButton: "display:inline-block;" + - "width:20px;" + - "height:20px;" + - "border:1px solid #555;" + - "border-radius:2px;" + - "margin:1px;" + - "padding:0;" + - "vertical-align:middle;" + - "text-decoration:none;", - - libraryPinButton: "display:block;" + - "margin:4px 0;" + - "padding:4px;" + - "border-radius:4px;" + - "background:#2a2a2a;" + - "border:1px solid #555;" + - "color:#fff;" + - "text-decoration:none;" + - "font-size:12px;" + - "white-space:nowrap;", - - libraryPinVisual: "display:inline-block;" + - "width:35px;" + - "height:35px;" + - "margin-right:6px;" + - "vertical-align:middle;" + - "border:1px solid #555;" + - "border-radius:4px;" + - "background-color:#000;", - - libraryPinText: "display:inline-block;" + - "vertical-align:middle;" - }; - } +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 splitButton(label, command) +{ + const css = getCSS(); - function iconSpriteButton(index, iconValue) - { - const offsetX = -(index * ICON_DISPLAY_SIZE); - - return ` -
    - - -
    - `; - } + return ( + `${label}` + + `++` + ); +} - function messageButton(label, command) - { - const css = getCSS(); +function messageButton(label, command) +{ + const css = getCSS(); - return ( - `${label}` - ); - } + return ( + `${label}` + ); +} - function showControlPanel() - { - const css = getCSS(); +function showControlPanel() +{ + const css = getCSS(); - const colors = [ - "#242424", "#307bb8", "#721211", "#e59a00", "#b40f69", "#2d0075", "#e26608", "#588b02", "#bb1804", - "#ffffff", "#000000" - ]; - - const colorButtons = colors.map((c, i) => - (i === colors.length - 2 ? "
    " : "") + - `` - ).join(''); - - const panel = - `
    ` + - - // SIZE - `
    Size
    ` + - splitButton("Teeny", "!pintool --set scale|teeny") + - splitButton("Tiny", "!pintool --set scale|tiny") + - splitButton("Small", "!pintool --set scale|small") + - splitButton("Medium", "!pintool --set scale|medium") + - `
    ` + - splitButton("Large", "!pintool --set scale|large") + - splitButton("Huge", "!pintool --set scale|huge") + - splitButton("Gigantic", "!pintool --set scale|gigantic") + - `
    ` + - - // VISIBILITY - `
    Visible
    ` + + 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") + - `
    ` + + splitButton("All Players", "!pintool --set visibleTo|all") + + `
    ` + - // BLOCKQUOTE - `
    Blockquote as player text
    ` + - splitButton("On", "!pintool --set autoNotesType|blockquote") + + `
    Blockquote as player text
    ` + + splitButton("On", "!pintool --set autoNotesType|blockquote") + splitButton("Off", "!pintool --set autoNotesType|") + - `
    ` + + `
    ` + - // DISPLAY SYNC - `
    Display
    ` + - splitButton("From Handout", "!pintool --set imageDesynced|false imageVisibleTo|") + + `
    Display
    ` + + splitButton("From Handout", "!pintool --set imageDesynced|false imageVisibleTo|") + splitButton("Custom", "!pintool --set imageDesynced|true imageVisibleTo|all") + - `
    ` + - - // CUSTOMIZATION MODE - `
    Customization Mode
    ` + - splitButton("Icon", "!pintool --set customizationType|icon") + - splitButton("Image", "!pintool --set customizationType|image") + - splitButton("Text", "!pintool --set useTextIcon|true") + - splitButton("Set Text", "!pintool --set iconText|?{Input up to 3 characters}") + `
    ` + - splitButton("Pin Text from Title", "!pintool --transform autotext") + - splitButton("Hide Names", "!pintool --set nameplateVisibleTo|") + - - - `
    ` + - - - // ICON QUICK PICKS - `
    Icon Presets
    ` + - ICON_ORDER.map((icon, i) => iconSpriteButton(i, icon)).join("") + - `
    ` + - - // PIN IMAGE - `
    Pin Image
    ` + - splitButton("Set Pin Image", "!pintool --set pinImage|?{Roll20 Image URL}") + - splitButton("Clear Image", "!pintool --set pinImage| customizationType|icon") + - `
    ` + - - // TOOLTIP IMAGE - `
    Tooltip Image
    ` + - splitButton("Set Tooltip Image", "!pintool --set tooltipImage|?{Roll20 Image URL}") + - splitButton("S", "!pintool --set tooltipImageSize|small") + - splitButton("M", "!pintool --set tooltipImageSize|medium") + - splitButton("L", "!pintool --set tooltipImageSize|large") + - splitButton("XL", "!pintool --set tooltipImageSize|xl") + - `
    ` + - - // SHAPE - `
    Shape
    ` + - splitButton("Teardrop", "!pintool --set shape|teardrop") + - splitButton("Circle", "!pintool --set shape|circle") + - splitButton("Diamond", "!pintool --set shape|diamond") + - splitButton("Square", "!pintool --set shape|square") + - `
    ` + - - // BACKGROUND COLOR - `
    Pin Colors
    ` + - colorButtons + - splitButton("Transparent", "!pintool --set bgColor|") + - splitButton("Custom Color", "!pintool --set bgColor|?{Enter custom color (hex or transparent)}") + - `
    ` + - - // Pin LIbrary - `
    Pin Library ` + - splitButton("See Styles", "!pintool --library") + - `
    ` + - - // SCALE PLACEMENT - `
    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 FROM HANDOUT - `
    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}") + - `
    ` + - - `
    `; + `
    ` + - sendStyledMessage( - "PinTool Control Panel", - panel - ); - } + `
    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) @@ -875,23 +578,19 @@ the command will display an error. 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)}".` - ); - } + 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(); + burndown(); } return; @@ -1049,305 +748,48 @@ the command will display an error. sendStyledMessage("PinTool — Warning", msg); } - -//Pin library functions - - function parseLibraryTitle(title) - { - const match = title.match(/\[(.*?)\]/); - if(!match) return null; - - const keywordBlock = match[1]; - const keywords = keywordBlock - .split(',') - .map(k => k.trim().toLowerCase()) - .filter(k => k.length); - - const cleanTitle = title.replace(/\s*\[.*?\]\s*/, '').trim(); - - return { - cleanTitle, - keywords - }; - } - - function getLibraryPage() - { - return findObjs( - { - _type: "page", - name: "Pin Library" - })[0]; - } - - -function showLibraryKeywords() -{ - const css = getCSS(); - const page = getLibraryPage(); - - if(!page) { - sendError("Pin Library page not found. Create a page named 'Pin Library' and add pins with keywords. See !pintool --help for details."); - return; - } - - const pins = findObjs( - { - _type: "pin", - _pageid: page.id - }); - - const keywordSet = new Set(); - - pins.forEach(pin => - { - const parsed = parseLibraryTitle(pin.get("title")); - if(!parsed) return; - - parsed.keywords.forEach(k => keywordSet.add(k)); - }); - - const keywords = Array.from(keywordSet).sort(); - - if(keywords.length === 0) { - sendError("No pins with keywords found on the Pin Library page. See !pintool --help to create them."); - return; - } - - const buttons = keywords.map(k => - `${k}` - ).join("
    "); - - const output = - `
    -
    Pin Library
    -
    - ${buttons} -
    -
    -${messageButton("Main Menu", "!pintool")} -
    -
    `; - - sendChat("PinTool", `/w gm ${output}`); -} - - -function buildLibraryPinButton(pin) { - const css = getCSS(); - const title = pin.get("title"); - const parsed = parseLibraryTitle(title); - if (!parsed) return ""; - - const cleanTitle = parsed.cleanTitle; - - const useTextIcon = pin.get("useTextIcon"); - const customizationType = pin.get("customizationType"); - const pinImage = pin.get("pinImage"); - const icon = pin.get("icon"); - const bgColor = pin.get("bgColor") || "#000"; - const iconText = pin.get("iconText"); - - let visual = ""; - - // Base styles for the visual div - const baseStyle = ` - width:35px; - height:35px; - display:inline-block; - vertical-align:middle; - border-radius:4px; - text-align:center; - line-height:35px; - font-weight:bold; - overflow:hidden; - background-size: auto 100%; - `; - - if (useTextIcon === true && iconText) { - // Text Icon - visual = `
    ${iconText.substring(0,3)}
    `; - } - else if (customizationType === "image" && pinImage) { - // Image pin — always light neutral gray behind - const grayBg = "#ccc"; - visual = `
    -
    `; - } -else if (customizationType === "icon" && icon) { - const iconIndex = ICON_ORDER.indexOf(icon); - const totalIcons = ICON_ORDER.length; - const bgPosPercent = (iconIndex / (totalIcons - 1)) * 100; - - visual = `
    -
    `; -} - else { - // Only color - visual = `
    `; - } - - return ` - ${visual} - ${cleanTitle} - `; -} - - - function showLibraryKeywordResults(keyword) - { - const css = getCSS(); - const page = getLibraryPage(); - if(!page) return; - - const lower = keyword.toLowerCase(); - - const pins = findObjs( - { - _type: "pin", - _pageid: page.id - }); - - const matches = pins.filter(pin => - { - const parsed = parseLibraryTitle(pin.get("title")); - if(!parsed) return false; - return parsed.keywords.includes(lower); - }); - - matches.sort((a, b) => - { - const pa = parseLibraryTitle(a.get("title")); - const pb = parseLibraryTitle(b.get("title")); - return pa.cleanTitle.localeCompare(pb.cleanTitle); - }); - - const buttons = matches.map(buildLibraryPinButton).join(""); - - const output = - `
    -
    Keyword: ${keyword}
    -
    - ${buttons} -
    -
    -${splitButton("Change Keyword", "!pintool --library")} - ${splitButton("Main Menu", "!pintool")} -
    -
    `; - - sendChat("PinTool", `/w gm ${output}`); - } - - - function copyLibraryPinToSelection(pinId, selected) - { - const libraryPin = getObj("pin", pinId); - if(!libraryPin) return; - - const targets = (selected || []) - .map(s => getObj(s._type, s._id)) - .filter(o => o && o.get("_type") === "pin"); - - if(!targets.length) - { - sendChat("PinTool", `/w gm No pins selected.`); - return; - } - - const props = libraryPin.attributes; - - targets.forEach(target => - { - Object.keys(props).forEach(key => - { - if([ - "title", - "link", - "linkType", - "subLink", - "subLinkType", - "_id", - "_type", - "x", - "y", - "notes", - "gmNotes", - "y", - "y", - "_pageid" - ].includes(key)) return; - - target.set(key, props[key]); - }); - }); - } - - - - - - - - - // ============================================================ // 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 isValidRoll20Image = (url) => { + return typeof url === 'string' && url.includes('files.d20.io/images'); +}; - const isRoll20Image = isValidRoll20Image(url); - let buttons = - `` + - `Send to All`; +function handleImageToChat(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 isRoll20Image = isValidRoll20Image(url); - const imageHtml = - `
    ` + - `` + - `
    ${buttons}
    ` + - `
    `; + let buttons = + `` + + `Send to All`; - sendChat( - "PinTool", - `/w "${sender}" ${imageHtml}`, - null, - { - noarchive: true - } - ); + if (isRoll20Image) { + buttons += + ` ` + + `Place image in Pin`; } + const imageHtml = + `
    ` + + `` + + `
    ${buttons}
    ` + + `
    `; + + sendChat( + "PinTool", + `/w "${sender}" ${imageHtml}`, + null, + { noarchive: true } + ); +} + function handleImageToChatAll(encodedUrl) @@ -1356,26 +798,24 @@ ${splitButton("Change Keyword", "!pintool --library")} if(!/^https?:\/\//i.test(url)) return sendError("Invalid image URL."); sendChat( - "PinTool", `
    `, - null, - { - noarchive: true - }); + "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 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 = { @@ -1383,15 +823,12 @@ ${splitButton("Change Keyword", "!pintool --library")} y: "number", title: "string", notes: "string", - - tooltipImage: "roll20image", - pinImage: "roll20image", - + image: "string", + tooltipImage: "string", link: "string", linkType: ["", "handout"], subLink: "string", subLinkType: ["", "headerPlayer", "headerGM"], - visibleTo: ["", "all"], tooltipVisibleTo: ["", "all"], nameplateVisibleTo: ["", "all"], @@ -1399,42 +836,16 @@ ${splitButton("Change Keyword", "!pintool --library")} notesVisibleTo: ["", "all"], gmNotesVisibleTo: ["", "all"], autoNotesType: ["", "blockquote"], - scale: { min: 0.25, max: 2.0 }, - imageDesynced: "boolean", notesDesynced: "boolean", - gmNotesDesynced: "boolean", - - bgColor: "color", - shape: ["teardrop", "circle", "diamond", "square"], - - customizationType: ["icon", "image"], - icon: [ - "base-dot", "base-castle", "base-skullSimple", "base-spartanHelm", - "base-radioactive", "base-heart", "base-star", "base-starSign", - "base-pin", "base-speechBubble", "base-file", "base-plus", - "base-circleCross", "base-dartBoard", "base-badge", "base-flagPin", - "base-crosshair", "base-scrollOpen", "base-diamond", "base-photo", - "base-fourStarShort", "base-circleStar", "base-lock", "base-crown", - "base-leaf", "base-signpost", "base-beer", "base-compass", "base-video", - "base-key", "base-chest", "base-village", "base-swordUp", "base-house", - "base-house2", "base-church", "base-government", "base-blacksmith", - "base-stable", "base-gear", "base-bridge", "base-mountain", - "base-exclamation", "base-question" - ], - - useTextIcon: "boolean", - iconText: "string", - - tooltipImageSize: ["small", "medium", "large", "xl"] + gmNotesDesynced: "boolean" }; - function handleSet(msg, tokens) { const flags = {}; @@ -1515,327 +926,132 @@ ${splitButton("Change Keyword", "!pintool --library")} if(!pins.length) return sendWarning("Filter matched no pins on the current page."); - try +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 queue = pins.map(p => p.id); - const BATCH_SIZE = 10; + const p = getObj("pin", id); + if(!p) return; + + const updates = {}; - const processBatch = () => + Object.entries(flags).forEach(([key, raw]) => { - const slice = queue.splice(0, BATCH_SIZE); + const spec = PIN_SET_PROPERTIES[key]; + let value = raw; - slice.forEach(id => + // Boolean + if(spec === "boolean") { - const p = getObj("pin", id); - if(!p) return; - - const updates = {}; + value = raw === "true"; + } - const originalCustomization = p.get("customizationType") || "icon"; - let newCustomization = originalCustomization; - let revertingFromText = false; + // Simple numeric (x, y) + else if(spec === "number") + { + const current = Number(p.get(key)); + const opMatch = raw.match(/^([+\-*/])\s*(-?\d*\.?\d+)$/); - Object.entries(flags).forEach(([key, raw]) => + if(opMatch) { - const spec = PIN_SET_PROPERTIES[key]; - let value = raw; - - // Boolean - if(spec === "boolean") + 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 === "/") { - value = raw === "true"; + if(operand === 0) throw 0; + value = current / operand; } + } + else + { + value = Number(raw); + if(isNaN(value)) throw 0; + } + } - // Roll20 image validation - else if(spec === "roll20image") - { - if(value && !isValidRoll20Image(value)) throw 0; - } + // Enumerated + else if(Array.isArray(spec)) + { + if(!spec.includes(value)) throw 0; + } - // Color validation - else if(spec === "color") - { - if(!/^(transparent|#[0-9a-fA-F]{6}([0-9a-fA-F]{2})?)$/.test(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; - // Simple numeric - else if(spec === "number") - { - const current = Number(p.get(key)); - const opMatch = raw.match(/^([+\-*/])\s*(-?\d*\.?\d+)$/); + 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 + 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 === "/") { - value = Number(raw); - if(isNaN(value)) throw 0; + if(operand === 0) throw 0; + value = current / operand; } } - - // Enumerated - else if(Array.isArray(spec)) + else { - if(!spec.includes(value)) throw 0; + value = Number(raw); + if(isNaN(value)) throw 0; } + } - // Bounded numeric - else if(typeof spec === "object") - { - const current = Number(p.get(key)); - const lower = spec.min; - const upper = spec.max; + value = Math.max(lower, Math.min(upper, value)); + } - 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)); - } - - // ---- Behavioral Rules ---- - - if(key === "pinImage") - { - if(value) - newCustomization = "image"; - } - -if(key === "icon") -{ - newCustomization = "icon"; - updates.useTextIcon = false; -} - - if(key === "iconText") - { - if(!value) - { - const title = updates.title ?? p.get("title") ?? ""; - value = title.substring(0, 3); - } - else - { - value = value.substring(0, 3); - } - - updates.useTextIcon = true; - } - -if(key === "useTextIcon") -{ - if(value === true) - { - newCustomization = "icon"; // text icons are a variation of icon mode - } - else - { - revertingFromText = true; - } -} - -if(key === "customizationType") -{ - newCustomization = value; - - if(value === "icon") - updates.useTextIcon = false; -} - - updates[key] = value; - }); - - // Final mode resolution (last flag wins) - if(revertingFromText) - { - updates.customizationType = originalCustomization; - } - else - { - updates.customizationType = newCustomization; - } - - // Prevent empty image mode - if(updates.customizationType === "image") - { - const finalImage = updates.pinImage ?? p.get("pinImage"); - if(!finalImage) - updates.customizationType = "icon"; - } - - 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).`); - } - -function deriveAutoText(str) -{ - if(!str) return ""; - - const startMatch = str.match(/[A-Za-z0-9]/); - if(!startMatch) return ""; - - const sliced = str.slice(startMatch.index); - - const tokenMatch = sliced.match(/^[A-Za-z0-9]+/); - if(!tokenMatch) return ""; - - const token = tokenMatch[0]; - - return token.length <= 3 - ? token - : token.substring(0, 3); -} - -function handleTransform(msg, argString) -{ - if(!argString) - return sendError("No transform specified."); - - const tokens = argString.split(/\s+/); - const transformType = tokens[0].toLowerCase(); - - if(transformType !== "autotext") - return sendError(`Unknown transform: ${transformType}`); - - // ---- Parse filter ---- - - let filterRaw = ""; - - const filterMatch = argString.match(/filter\|(.+)/i); - if(filterMatch) - filterRaw = filterMatch[1].trim(); - - const pageId = getPageForPlayer(msg.playerid); - - 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("Transform matched no pins on the current page."); - - 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 title = p.get("title") || ""; - const subLink = p.get("subLink") || ""; - - const source = title.trim() ? title : subLink; - const derived = deriveAutoText(source); - - if(!derived) return; - - p.set({ - customizationType: "icon", - useTextIcon: true, - iconText: derived + updates[key] = value; }); - // force refresh + 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) @@ -1853,609 +1069,585 @@ function handleTransform(msg, argString) // CONVERT MODE // ============================================================ - function handleConvert(msg, tokens) - { + function handleConvert(msg, tokens) + { - if(!tokens.length) - { - sendConvertHelp(); - return; - } + if(!tokens.length) + { + sendConvertHelp(); + return; + } - // ---------------- Parse convert specs (greedy tail preserved) ---------------- - const flags = {}; - const orderedSpecs = []; + // ---------------- Parse convert specs (greedy tail preserved) ---------------- + 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; + + 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( { - const t = tokens[i]; - const idx = t.indexOf("|"); - if(idx === -1) continue; + key, + val + }); + i = j - 1; + } - const key = t.slice(0, idx).toLowerCase(); - let val = t.slice(idx + 1); + // ---------------- Required args ---------------- + if(!flags.title) return sendError("--convert requires title|"); + if(!flags.name) return sendError("--convert requires name|h1–h5"); - const parts = [val]; - let j = i + 1; + const nameMatch = flags.name.match(/^h([1-5])$/i); + if(!nameMatch) return sendError("name must be h1 through h5"); - while(j < tokens.length) - { - const next = tokens[j]; - if(next.indexOf("|") !== -1) break; - parts.push(next); - j++; - } + const nameHeaderLevel = parseInt(nameMatch[1], 10); + const minAllowedHeader = Math.min(nameHeaderLevel + 1, 6); - val = parts.join(" "); - flags[key] = val; - orderedSpecs.push( - { - key, - val - }); - i = j - 1; - } + const supernotes = flags.supernotesgmtext === "true"; + const imagelinks = flags.imagelinks === "true"; + const replace = flags.replace === "true"; // NEW - // ---------------- Required args ---------------- - if(!flags.title) return sendError("--convert requires title|"); - if(!flags.name) return sendError("--convert requires name|h1–h5"); + // ---------------- Token validation ---------------- + if(!msg.selected || !msg.selected.length) + { + sendError("Please select a token."); + return; + } - const nameMatch = flags.name.match(/^h([1-5])$/i); - if(!nameMatch) return sendError("name must be h1 through h5"); + const selectedToken = getObj("graphic", msg.selected[0]._id); + if(!selectedToken) return sendError("Invalid token selection."); - const nameHeaderLevel = parseInt(nameMatch[1], 10); - const minAllowedHeader = Math.min(nameHeaderLevel + 1, 6); + 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; + } - const supernotes = flags.supernotesgmtext === "true"; - const imagelinks = flags.imagelinks === "true"; - const replace = flags.replace === "true"; // NEW + // ---------------- Helpers ---------------- + const decodeUnicode = str => + str.replace(/%u[0-9A-Fa-f]{4}/g, m => + String.fromCharCode(parseInt(m.slice(2), 16)) + ); - // ---------------- Token validation ---------------- - if(!msg.selected || !msg.selected.length) + function decodeNotes(raw) + { + if(!raw) return ""; + let s = decodeUnicode(raw); + try + { + s = decodeURIComponent(s); + } + catch + { + try { - sendError("Please select a token."); - return; + s = unescape(s); } - - 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( + catch (e) { - _type: "graphic", - _subtype: "token", - _pageid: pageId, - represents: charId - }); - - if(!tokensOnPage.length) - { - sendError("No matching map tokens found."); - return; + log(e); } + } + return s.replace(/^]*>/i, "").replace(/<\/div>$/i, "").trim(); + } - // ---------------- 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 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}
    `; - function applyBlockquoteSplit(html) - { - const blocks = html.match(//gi); - if(!blocks) return `
    ${html}
    `; + const idx = blocks.findIndex( + b => normalizeVisibleText(b) === "-----" + ); - const idx = blocks.findIndex( - b => normalizeVisibleText(b) === "-----" - ); + // NEW: no separator → everything is player-visible + if(idx === -1) + { + return `
    ${blocks.join("")}
    `; + } - // 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(""); - // Separator exists → split as before - const player = blocks.slice(0, idx).join(""); - const gm = blocks.slice(idx + 1).join(""); + return `
    ${player}
    \n${gm}`; + } - 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 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 encodeProtocol(url) + 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) { - return url.replace(/^(https?):\/\//i, "$1!!!"); + out += `
    [Image]`; } + return out; + } + ); + + if(imagelinks) + { + html = html.replace( + /(]*\bsrc=["']([^"']+)["'][^>]*>)(?![\s\S]*?\[Image\])/gi, + (m, img, url) => + `${img}
    [Image]` + ); + } - 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; - } - ); + return html; + } - if(imagelinks) - { - html = html.replace( - /(]*\bsrc=["']([^"']+)["'][^>]*>)(?![\s\S]*?\[Image\])/gi, - (m, img, url) => - `${img}
    [Image]` - ); - } + 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; + } - return html; - } + // ---------------- Build output ---------------- + const output = []; + const tokenByName = {}; // NEW: exact name → token + const pinsToCreateCache = new Set(); - function applyFormat(content, format) + let workTokensOnPage = tokensOnPage + .sort((a, b) => (a.get("name") || "").localeCompare(b.get("name") || "", undefined, { - 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; - } - + 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; - // ---------------- Build output ---------------- - const output = []; - const tokenByName = {}; // NEW: exact name → token - const pinsToCreateCache = new Set(); + const skipped = []; +// const headerRegex = new RegExp(`([\\s\\S]*?)<\\/h${nameHeaderLevel}>`, "gi"); + + const headers = [...pinsToCreateCache]; - let workTokensOnPage = tokensOnPage - .sort((a, b) => (a.get("name") || "").localeCompare(b.get("name") || "", undefined, - { - sensitivity: "base" - })); + const replaceBurndown = () => { + let header = headers.shift(); + if( header ) { + const headerText = _.unescape(header).trim(); + const token = tokenByName[headerText]; + if(!token) + { + skipped.push(headerText); + return; + } - const finishUp = () => - { - // ---------------- Handout creation ---------------- - let h = findObjs( + const existingPin = findObjs( { - _type: "handout", - name: flags.title + _type: "pin", + _pageid: pageId, + link: handoutId, + subLink: headerText })[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]; + 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 + }); - const replaceBurndown = () => + if(pin) { - let header = headers.shift(); - if(header) + pin.set( { - 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]; - + link: handoutId, + linkType: "handout", + subLink: headerText + }); + } + } + setTimeout(replaceBurndown,0); + } else { - if(existingPin) - { - existingPin.set( - { - x: token.get("left"), - y: token.get("top"), - link: handoutId, - linkType: "handout", - subLink: headerText - }); + if(skipped.length) + { + sendStyledMessage( + "Convert: Pins Skipped", + `
      ${skipped.map(s => `
    • ${_.escape(s)}
    • `).join("")}
    ` + ); + } else { + sendStyledMessage( + "Finished Adding Pins", + `Created ${pinsToCreateCache.size} Map Pins.` + ); + } + } + }; + replaceBurndown(); + }; - } - else - { - // Two-step pin creation to avoid desync errors - const pin = + const burndown = ()=>{ + let token = workTokensOnPage.shift(); + if(token) { + const tokenName = token.get("name") || ""; + tokenByName[tokenName] = token; // exact string match - 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 - { + output.push(`${_.escape(tokenName)}`); + pinsToCreateCache.add(_.escape(tokenName)); - if(skipped.length) - { - sendStyledMessage( - "Convert: Pins Skipped", - `
      ${skipped.map(s => `
    • ${_.escape(s)}
    • `).join("")}
    ` - ); - } - else - { - sendStyledMessage( - "Finished Adding Pins", - `Created ${pinsToCreateCache.size} Map Pins.` - ); - } - } - }; - replaceBurndown(); - }; + orderedSpecs.forEach(spec => + { + if(["name", "title", "supernotesgmtext", "imagelinks", "replace"].includes(spec.key)) return; - const burndown = () => - { - let token = workTokensOnPage.shift(); - if(token) + let value = ""; + if(spec.key === "gmnotes") { - 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); + value = decodeNotes(token.get("gmnotes") || ""); + if(supernotes) value = applyBlockquoteSplit(value); + value = downgradeHeaders(value); + value = convertImages(value); } - else + else if(spec.key === "tooltip") { - finishUp(); + value = token.get("tooltip") || ""; + } + else if(/^bar[1-3]_(value|max)$/.test(spec.key)) + { + value = token.get(spec.key) || ""; } - }; - burndown(); + if(value) output.push(applyFormat(value, spec.val)); + }); + setTimeout(burndown,0); + } else { + finishUp(); + } + }; - } + burndown(); + + } // ============================================================ // PLACE MODE // ============================================================ - function handlePlace(msg, args) - { + function handlePlace(msg, args) + { - if(!args.length) return; + if(!args.length) return; - /* ---------------- Parse args ---------------- */ - const flags = {}; + /* ---------------- Parse args ---------------- */ + const flags = {}; - for(let i = 0; i < args.length; i++) - { - const t = args[i]; - const idx = t.indexOf("|"); - if(idx === -1) continue; + 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 key = t.slice(0, idx).toLowerCase(); + let val = t.slice(idx + 1); - const parts = [val]; - let j = i + 1; + const parts = [val]; + let j = i + 1; - while(j < args.length && args[j].indexOf("|") === -1) - { - parts.push(args[j]); - j++; - } + while(j < args.length && args[j].indexOf("|") === -1) + { + parts.push(args[j]); + j++; + } - flags[key] = parts.join(" "); - i = j - 1; - } + 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|"); + 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 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; + 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.`); + /* ---------------- 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; + const handout = handouts[0]; + const handoutId = handout.id; - /* ---------------- Page ---------------- */ - const pageId = getPageForPlayer(msg.playerid); + /* ---------------- Page ---------------- */ + const pageId = getPageForPlayer(msg.playerid); - if(typeof pageId === "undefined") - return sendError("pageId is not defined."); + if(typeof pageId === "undefined") + return sendError("pageId is not defined."); - const page = getObj("page", pageId); - if(!page) return sendError("Invalid pageId."); + 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 gridSize = page.get("snapping_increment") * 70 || 70; + const maxCols = Math.floor((page.get("width") * 70) / gridSize); - const startX = gridSize / 2; - const startY = gridSize / 2; + const startX = gridSize / 2; + const startY = gridSize / 2; - let col = 0; - let row = 0; + let col = 0; + let row = 0; - /* ---------------- Header extraction ---------------- */ - const headerRegex = new RegExp( - `([\\s\\S]*?)<\\/h${headerLevel}>`, - "gi" - ); + /* ---------------- Header extraction ---------------- */ + const headerRegex = new RegExp( + `([\\s\\S]*?)<\\/h${headerLevel}>`, + "gi" + ); - const headers = []; // { text, subLinkType } + 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 - }); - } - } +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")); + handout.get("notes", html => extractHeaders(html, "headerPlayer")); + handout.get("gmnotes", html => extractHeaders(html, "headerGM")); - if(!headers.length) - return sendError(`No headers found in handout.`); + if(!headers.length) + return sendError(`No headers found in handout.`); - /* ---------------- Existing pins ---------------- */ - const existingPins = findObjs( - { - _type: "pin", - _pageid: pageId, - link: handoutId - }); + /* ---------------- 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; - }); + const pinByKey = {}; + existingPins.forEach(p => + { + const key = `${p.get("subLink")}||${p.get("subLinkType") || ""}`; + pinByKey[key] = p; + }); - let created = 0; - let replaced = 0; + let created = 0; + let replaced = 0; - /* ---------------- Placement ---------------- */ - const burndown = () => - { - let h = headers.shift(); - if(h) - { + /* ---------------- Placement ---------------- */ + const burndown = () => { + let h = headers.shift(); + if(h) { - const headerText = h.text; - const subLinkType = h.subLinkType; - const key = `${headerText}||${subLinkType}`; + const headerText = h.text; + const subLinkType = h.subLinkType; + const key = `${headerText}||${subLinkType}`; - let x, y; - const existing = pinByKey[key]; + 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; + 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); + // 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++; - } + 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 + // Two-step creation (same defaults as convert) + createObj("pin", { - /* ---------------- Report ---------------- */ - sendStyledMessage( - "Place Pins", - `

    Handout: ${_.escape(handoutName)}

    + 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(); + ); + } + }; + burndown(); + + } - } @@ -2467,73 +1659,28 @@ function handleTransform(msg, argString) on("chat:message", msg => { if(msg.type !== "api" || !/^!pintool\b/i.test(msg.content)) return; - sender = msg.who.replace(/\s\(GM\)$/, ''); +const parts = msg.content.trim().split(/\s+/); +const cmd = parts[1]?.toLowerCase(); - const parts = msg.content.trim().split(/\s+/); - const cmd = parts[1]?.toLowerCase(); - - if(parts.length === 1) - { - showControlPanel(); - return; - } +if(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 === "--library") -{ - // Rebuild everything after --library, preserving spaces - const argString = msg.content - .replace(/^!pintool\s+--library\s*/i, "") - .trim(); - - if(!argString) - return showLibraryKeywords(); - - if(argString.startsWith("keyword|")) - return showLibraryKeywordResults(argString.slice(8)); - - if(argString.startsWith("copy|")) - return copyLibraryPinToSelection(argString.slice(5), msg.selected); - - return sendError("Invalid --library syntax."); -} - - if(cmd?.startsWith("--imagetochat|")) return handleImageToChat(parts[1].slice(14)); - if(cmd?.startsWith("--imagetochatall|")) return handleImageToChatAll(parts[1].slice(17)); - - - if(cmd === "--transform") - { - const argString = msg.content - .replace(/^!pintool\s+--transform\s*/i, "") - .trim(); - return handleTransform(msg, argString); - } 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); - } -} \ No newline at end of file +{try{throw new Error('');}catch(e){API_Meta.PinTool.lineCount=(parseInt(e.stack.split(/\n/)[1].replace(/^.*:(\d+):.*$/,'$1'),10)-API_Meta.PinTool.offset);}} From 1f06361f77eb20ab9d01b82b0d9a29a5d7f4f004 Mon Sep 17 00:00:00 2001 From: keithcurtis1 Date: Mon, 2 Mar 2026 22:47:20 -0800 Subject: [PATCH 8/9] Fix typos and enhance clarity in readme.md Corrected typos and improved clarity in the readme. --- PinTool/readme.md | 41 +++++++++++++++++++---------------------- 1 file changed, 19 insertions(+), 22 deletions(-) diff --git a/PinTool/readme.md b/PinTool/readme.md index 7f0282e0d..69b238c9d 100644 --- a/PinTool/readme.md +++ b/PinTool/readme.md @@ -1,42 +1,39 @@ # PinTool -PinTool 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 into Roll20’s newer map pin system, allowing structured handouts and pins to stay in sync. +PinTool 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. --- ## Core Capabilities -- Bulk modification of map pin properties. -- Precise targeting of selected pins, all pins on a page, or explicit pin IDs. -- Conversion of legacy token notes into structured handouts. -- Automatic placement of map pins from handout headers (player and GM). -- Optional chat display of images referenced in notes. -- **Pin Library (`--library`)** lets GMs Keep a library of pin styles for quick application. +- Bulk modification of map pin properties +- Precise targeting of selected pins, all pins on a page, or explicit pin IDs +- Conversion of legacy note tokens into structured handouts +- Automatic placement of map pins from handout headers (player and GM) +- Optional chat display of images referenced in notes -**Base Command:** `!pintool` opens a control panel for commonly used editing controls. Add primary commands afterward to access specific functions. +**Base Command:** `!pintool` opens a control panel for commonly used editing controls. Add priaru commands afterward to access specific functions. + +`!pintool --help` creates a handout with full documentation -`!pintool --help` creates a handout with full documentation. --- ## Primary Commands -- `--set` — Update one or more properties across many pins at once. -- `--convert` — Extract data from tokens representing the same character and build or update a handout. -- `--place` — Create or replace pins based on handout headers, linking directly to those sections. -- `--purge` — Remove related tokens or pins in bulk. -- `--library` — Open the Pin Library to copy preset pin styles to selected pins. -- `--transform` — Apply transformations to pins, e.g., auto-generating icon text from titles. -- `--help` — Display the full PinTool help panel. +- `--set` updates one or more properties across many pins at once. +- `--convert` extracts data from tokens representing the same character and builds or updates a handout. +- `--place` scans a handout for headers and creates or replaces pins linked directly to those sections. +- `--purge` removes related tokens or pins in bulk. +- `--help` creates full documentation handout. --- ## Highlights -- Pins created via `--place` link directly to specific headers in Notes or GM Notes. -- Existing pins are replaced in-place, preserving their positions. -- Conversion supports header levels, blockquotes, code blocks, and inline image links. -- Visibility, scale, links, and sync state can all be controlled programmatically. -- Pin customization modes allow you to quickly switch the pin image between icons, text icons, or images. +- Pins created via `--place` link directly to specific headers in Notes or GM Notes. +- Existing pins are replaced in-place, preserving their positions. +- Conversion supports header levels, blockquotes, code blocks, and inline image links. +- Visibility, scale, links, and sync state can all be controlled programmatically. -Designed for GMs who want more automated control over pin placement, appearance, and management. \ No newline at end of file +Designed for GMs who want more automated control over pin placement and management. From f5e14656f1e6695494b78f0f259eacc0a48f23a6 Mon Sep 17 00:00:00 2001 From: keithcurtis1 Date: Mon, 2 Mar 2026 22:48:05 -0800 Subject: [PATCH 9/9] Update description format in script.json --- PinTool/script.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/PinTool/script.json b/PinTool/script.json index e06cd8ed6..fc66979ec 100644 --- a/PinTool/script.json +++ b/PinTool/script.json @@ -2,7 +2,7 @@ "name": "PinTool", "script": "PinTool.js", "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 into 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 token notes 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- **Pin Library (`--library`)** lets GMs Keep a library of pin styles for quick application.\\n\\n**Base Command:** `!pintool` opens a control panel for commonly used editing controls. Add primary commands afterward to access specific functions.\\n\\n`!pintool --help` creates a handout with full documentation.\\n\\n---\\n\\n## Primary Commands\\n\\n- `--set` — Update one or more properties across many pins at once.\\n- `--convert` — Extract data from tokens representing the same character and build or update a handout.\\n- `--place` — Create or replace pins based on handout headers, linking directly to those sections.\\n- `--purge` — Remove related tokens or pins in bulk.\\n- `--library` — Open the Pin Library to copy preset pin styles to selected pins.\\n- `--transform` — Apply transformations to pins, e.g., auto-generating icon text from titles.\\n- `--help` — Display the full PinTool help panel.\\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- Pin customization modes allow you to quickly switch the pin image between icons, text icons, or images.\\n\\nDesigned for GMs who want more automated control over pin placement, appearance, and management.", + "description": "# PinTool\n\nPinTool is a GM-only Roll20 API script for creating, inspecting, converting, and managing **map pins** at scale. It can convert older token-based note workflows with Roll20’s newer map pin system, allowing structured handouts and pins to stay in sync.\n\n---\n\n## Core Capabilities\n\n- Bulk modification of map pin properties\n- Precise targeting of selected pins, all pins on a page, or explicit pin IDs\n- Conversion of legacy note tokens into structured handouts\n- Automatic placement of map pins from handout headers (player and GM)\n- Optional chat display of images referenced in notes\n\n**Base Command:** `!pintool`\n\n---\n\n## Primary Commands\n\n```\n!pintool --set\n!pintool --convert\n!pintool --place\n!pintool --purge\n!pintool --help\n```\n\n- `--set` updates one or more properties across many pins at once.\n- `--convert` extracts data from tokens representing the same character and builds or updates a handout.\n- `--place` scans a handout for headers and creates or replaces pins linked directly to those sections.\n- `--purge` removes related tokens or pins in bulk.\n\n---\n\n## Highlights\n\n- Pins created via `--place` link directly to specific headers in Notes or GM Notes.\n- Existing pins are replaced in-place, preserving their positions.\n- Conversion supports header levels, blockquotes, code blocks, and inline image links.\n- Visibility, scale, links, and sync state can all be controlled programmatically.\n\nDesigned for GMs who want more automated control over pin placement and management.\n\nType **!pintool** in chat for a handy control panel.", "authors": "Keith Curtis", "roll20userid": "162065", "dependencies": [], @@ -12,4 +12,4 @@ }, "conflicts": [], "previousversions": ["1.0.0","1.0.1","1.0.2"] -} \ No newline at end of file +}