diff --git a/PinTool/1.0.1/PinTool.js b/PinTool/1.0.1/PinTool.js new file mode 100644 index 000000000..1966ac318 --- /dev/null +++ b/PinTool/1.0.1/PinTool.js @@ -0,0 +1,1381 @@ +// 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.1'; //version number set here + log('-=> PinTool v' + version + ' is loaded. Use !pintool --help for documentation.'); + //1.0.1 Added burndown to many parts to account for timeouts - Thanks to the Aaron + //1.0.0 Debut + + + // ============================================================ + // HELPERS + // ============================================================ + + const scriptName = "PinTool"; + const PINTOOL_HELP_NAME = "Help: PinTool"; + const PINTOOL_HELP_AVATAR = "https://files.d20.io/images/470559564/QxDbBYEhr6jLMSpm0x42lg/original.png?1767857147"; + + const PINTOOL_HELP_TEXT = ` +

PinTool Script Help

+ +

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

+ + + +

Base Command: !pintool

+ +

Primary Commands

+ + + +
+ +

Set Command

+ +

Format:

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

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

+ +

Filter Options

+ + + +

Settable Properties

+ +

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

+ +

Position

+ + +

Text & Content

+ + +

Links

+ + +

Visibility

+ + +

Notes Behavior

+ + +

Appearance

+ + +

State

+ + +
+ +

Convert Command

+ +

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

+ +

Format:

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

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

+ +

Required Arguments

+ + + +

Optional Arguments

+ + + +

Format may be:

+ + +

Behavior Flags

+ + + +

Convert Rules

+ + + +
+ +

Place Command

+ +

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

+ +

Format:

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

Required Arguments

+ + + + + + +

Behavior

+ + + +

Notes

+ + + +
+ +

Purge Command

+ +

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

+ +

Format:

+
+!pintool --purge tokens
+
+ +

Required Arguments

+ + +
+ +

Example Macros

+ + + +
+ +

General Rules

+ + +`; + + 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:13px;" + + "margin-bottom:6px;" + + "color:#fff;", + messageButton: "display:inline-block;" + + "padding:2px 6px;" + + "margin:2px 0;" + + "border-radius:4px;" + + "background:#333;" + + "border:1px solid #555;" + + "color:#9fd3ff;" + + "text-decoration:none;" + + "font-size:12px;" + }; + } + + function handlePurge(msg, args) + { + if(!args.length) return; + + const mode = args[0]; + if(mode !== "tokens" && mode !== "pins") return; + + const confirmed = args.includes("--confirm"); + + // -------------------------------- + // CONFIRM PATH (no selection) + // -------------------------------- + if(confirmed) + { + let charId, handoutId, pageId; + + args.forEach(a => + { + if(a.startsWith("char|")) charId = a.slice(5); + if(a.startsWith("handout|")) handoutId = a.slice(8); + if(a.startsWith("page|")) pageId = a.slice(5); + }); + + if(!pageId) return; + + /* ===== PURGE TOKENS (CONFIRM) ===== */ + if(mode === "tokens" && charId) + { + const char = getObj("character", charId); + if(!char) return; + + const charName = char.get("name") || "Unknown Character"; + + const targets = findObjs( + { + _type: "graphic", + _subtype: "token", + _pageid: pageId, + represents: charId + }); + + if(!targets.length) return; + + targets.forEach(t => t.remove()); + + sendChat( + "PinTool", + `/w gm ✅ Deleted ${targets.length} token(s) for "${_.escape(charName)}".` + ); + } + + /* ===== PURGE PINS (CONFIRM) ===== */ + if(mode === "pins" && handoutId) + { + const handout = getObj("handout", handoutId); + if(!handout) return; + + const handoutName = handout.get("name") || "Unknown Handout"; + + const targets = findObjs( + { + _type: "pin", + _pageid: pageId + }).filter(p => p.get("link") === handoutId); + + if(!targets.length) return; + + const count = targets.length; + + const burndown = () => { + let p = targets.shift(); + if(p){ + p.remove(); + setTimeout(burndown,0); + } else { + sendChat( + "PinTool", + `/w gm ✅ Deleted ${count} pin(s) linked to "${_.escape(handoutName)}".` + ); + } + }; + burndown(); + } + + return; + } + + // -------------------------------- + // INITIAL PATH (requires selection) + // -------------------------------- + if(!msg.selected || msg.selected.length !== 1) return; + + const sel = msg.selected[0]; + + /* =============================== + PURGE TOKENS (INITIAL) + =============================== */ + if(mode === "tokens" && sel._type === "graphic") + { + const token = getObj("graphic", sel._id); + if(!token) return; + + const charId = token.get("represents"); + if(!charId) return; + + const pageId = token.get("_pageid"); + const char = getObj("character", charId); + const charName = char?.get("name") || "Unknown Character"; + + const targets = findObjs( + { + _type: "graphic", + _subtype: "token", + _pageid: pageId, + represents: charId + }); + + if(!targets.length) return; + + sendStyledMessage( + "Confirm Purge", + ` +
+
+ This will permanently delete ${targets.length} token(s) +
+
+ representing ${_.escape(charName)} on this page. +
+ +
+ This cannot be undone. +
+ +
+ + Click here to confirm + +
+
+ ` + ); + + return; + } + + /* =============================== + PURGE PINS (INITIAL) + =============================== */ + if(mode === "pins" && sel._type === "pin") + { + const pin = getObj("pin", sel._id); + if(!pin) return; + + const handoutId = pin.get("link"); + if(!handoutId) return; + + const pageId = pin.get("_pageid"); + const handout = getObj("handout", handoutId); + const handoutName = handout?.get("name") || "Unknown Handout"; + + const targets = findObjs( + { + _type: "pin", + _pageid: pageId + }).filter(p => p.get("link") === handoutId); + + if(!targets.length) return; + + sendStyledMessage( + "Confirm Purge", + `

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

+

This cannot be undone.

+

+ + Click here to confirm + +

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

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

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

Handout: ${_.escape(handoutName)}

+
    +
  • Pins created: ${created}
  • +
  • Pins replaced: ${replaced}
  • +
` + ); + } + }; + burndown(); + + } + + + + + + // ============================================================ + // CHAT DISPATCH + // ============================================================ + + on("chat:message", msg => + { + if(msg.type !== "api" || !/^!pintool\b/i.test(msg.content)) return; + + const parts = msg.content.trim().split(/\s+/); + const cmd = parts[1]?.toLowerCase(); + + if(cmd === "--set") return handleSet(msg, parts.slice(2)); + if(cmd === "--convert") return handleConvert(msg, parts.slice(2)); + if(cmd === "--place") return handlePlace(msg, parts.slice(2)); + if(cmd === "--purge") return handlePurge(msg, parts.slice(2)); + if(cmd === "--help") return handleHelp(msg); + if(cmd?.startsWith("--imagetochat|")) + return handleImageToChat(parts[1].slice(14)); + + sendError("Unknown subcommand. Use --help."); + }); +}); + +{try{throw new Error('');}catch(e){API_Meta.PinTool.lineCount=(parseInt(e.stack.split(/\n/)[1].replace(/^.*:(\d+):.*$/,'$1'),10)-API_Meta.PinTool.offset);}} diff --git a/PinTool/PinTool.js b/PinTool/PinTool.js index 473d47212..1966ac318 100644 --- a/PinTool/PinTool.js +++ b/PinTool/PinTool.js @@ -8,8 +8,10 @@ API_Meta.PinTool={offset:Number.MAX_SAFE_INTEGER,lineCount:-1}; on("ready", () => { - const version = '1.0.0'; //version number set here + const version = '1.0.1'; //version number set here log('-=> PinTool v' + version + ' is loaded. Use !pintool --help for documentation.'); + //1.0.1 Added burndown to many parts to account for timeouts - Thanks to the Aaron + //1.0.0 Debut // ============================================================ @@ -444,12 +446,21 @@ The purge command removes all tokens on the map similar to the if(!targets.length) return; - targets.forEach(p => p.remove()); - - sendChat( - "PinTool", - `/w gm ✅ Deleted ${targets.length} pin(s) linked to "${_.escape(handoutName)}".` - ); + 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; @@ -760,8 +771,7 @@ The purge command removes all tokens on the map similar to the return sendError("Invalid value supplied to --set."); } pins.forEach(p => p.set(updates)); - - //sendStyledMessage("PinTool — Success", `Updated ${pins.length} pin(s).`); + sendStyledMessage("PinTool — Success", `Updated ${pins.length} pin(s).`); } // ============================================================ @@ -780,360 +790,376 @@ The purge command removes all tokens on the map similar to the // 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; - - 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; - } + 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"); + // ---------------- 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 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 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 + 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; - } + // ---------------- 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 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 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 - }); + const tokensOnPage = findObjs( + { + _type: "graphic", + _subtype: "token", + _pageid: pageId, + represents: charId + }); - if(!tokensOnPage.length) - { - sendError("No matching map tokens found."); - return; - } + 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)) - ); + // ---------------- Helpers ---------------- + const decodeUnicode = str => + str.replace(/%u[0-9A-Fa-f]{4}/g, m => + String.fromCharCode(parseInt(m.slice(2), 16)) + ); - function decodeNotes(raw) + function decodeNotes(raw) + { + if(!raw) return ""; + let s = decodeUnicode(raw); + try + { + s = decodeURIComponent(s); + } + catch + { + try { - if(!raw) return ""; - let s = decodeUnicode(raw); - try - { - s = decodeURIComponent(s); - } - catch - { - try - { - s = unescape(s); - } - catch - {} - } - return s.replace(/^]*>/i, "").replace(/<\/div>$/i, "").trim(); + s = unescape(s); } - - function normalizeVisibleText(html) + catch (e) { - return html - .replace(//gi, "\n") - .replace(/<\/p\s*>/gi, "\n") - .replace(/<[^>]+>/g, "") - .replace(/ /gi, " ") - .replace(/\s+/g, " ") - .trim(); + log(e); } + } + return s.replace(/^]*>/i, "").replace(/<\/div>$/i, "").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(""); + function normalizeVisibleText(html) + { + return html + .replace(//gi, "\n") + .replace(/<\/p\s*>/gi, "\n") + .replace(/<[^>]+>/g, "") + .replace(/ /gi, " ") + .replace(/\s+/g, " ") + .trim(); + } - return `
${player}
\n${gm}`; - } + function applyBlockquoteSplit(html) + { + const blocks = html.match(//gi); + if(!blocks) return `
${html}
`; + const idx = blocks.findIndex( + b => normalizeVisibleText(b) === "-----" + ); - function downgradeHeaders(html) - { - return html - .replace(/<\s*h[1-2]\b[^>]*>/gi, "

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

"); - } + // NEW: no separator → everything is player-visible + if(idx === -1) + { + return `
${blocks.join("")}
`; + } - function encodeProtocol(url) - { - return url.replace(/^(https?):\/\//i, "$1!!!"); - } + // Separator exists → split as before + const player = blocks.slice(0, idx).join(""); + const gm = blocks.slice(idx + 1).join(""); - function convertImages(html) - { - if(!html) return html; + return `
${player}
\n${gm}`; + } - 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]` - ); - } + function downgradeHeaders(html) + { + return html + .replace(/<\s*h[1-2]\b[^>]*>/gi, "

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

"); + } - return html; - } + function encodeProtocol(url) + { + return url.replace(/^(https?):\/\//i, "$1!!!"); + } - function applyFormat(content, format) + 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) { - 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; + out += `
[Image]`; } + return out; + } + ); + + if(imagelinks) + { + html = html.replace( + /(]*\bsrc=["']([^"']+)["'][^>]*>)(?![\s\S]*?\[Image\])/gi, + (m, img, url) => + `${img}
[Image]` + ); + } - // ---------------- Build output ---------------- - const output = []; - const tokenByName = {}; // NEW: exact name → token + return html; + } - tokensOnPage - .sort((a, b) => (a.get("name") || "").localeCompare(b.get("name") || "", undefined, - { - sensitivity: "base" - })) - .forEach(token => - { + 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; + } - const tokenName = token.get("name") || ""; - tokenByName[tokenName] = token; // exact string match + // ---------------- Build output ---------------- + const output = []; + const tokenByName = {}; // NEW: exact name → token + const pinsToCreateCache = new Set(); - output.push(`${_.escape(tokenName)}`); + let workTokensOnPage = tokensOnPage + .sort((a, b) => (a.get("name") || "").localeCompare(b.get("name") || "", undefined, + { + sensitivity: "base" + })); - 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)); - }); - }); - // ---------------- Handout creation ---------------- - let h = findObjs( + const finishUp = () => { + // ---------------- Handout creation ---------------- + let h = findObjs( { - _type: "handout", - name: flags.title + _type: "handout", + name: flags.title })[0]; - if(!h) h = createObj("handout", + if(!h) h = createObj("handout", { - name: flags.title + name: flags.title }); - h.set("notes", output.join("\n")); - const handoutId = h.id; + h.set("notes", output.join("\n")); + const handoutId = h.id; - sendChat("PinTool", `/w gm Handout "${flags.title}" updated.`); + sendChat("PinTool", `/w gm Handout "${flags.title}" updated.`); - if(!replace) return; + if(!replace) return; - // ---------------- Replace pins (ASYNC SAFE) ---------------- - h.get("notes", function(notes) - { + 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]; - const skipped = []; - const headerRegex = new RegExp(`([\\s\\S]*?)<\\/h${nameHeaderLevel}>`, "gi"); - let match; + if(!token) + { + skipped.push(headerText); + return; + } - while((match = headerRegex.exec(notes)) !== null) + const existingPin = findObjs( { - const headerText = _.unescape(match[1]).trim(); - const token = tokenByName[headerText]; + _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 + }); - if(!token) + } + else + { + // Two-step pin creation to avoid desync errors + const pin = + + createObj("pin", { - skipped.push(headerText); - continue; - } + 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 existingPin = findObjs( + if(pin) + { + pin.set( { - _type: "pin", - _pageid: pageId, - link: handoutId, - subLink: headerText - })[0]; + 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(); + }; - if(existingPin) - { - existingPin.set( - { - x: token.get("left"), - y: token.get("top"), - link: handoutId, - linkType: "handout", - subLink: headerText - }); + const burndown = ()=>{ + let token = workTokensOnPage.shift(); + if(token) { + const tokenName = token.get("name") || ""; + tokenByName[tokenName] = token; // exact string match - } - 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 - }); - } - } - } + output.push(`${_.escape(tokenName)}`); + pinsToCreateCache.add(_.escape(tokenName)); - if(skipped.length) + orderedSpecs.forEach(spec => + { + if(["name", "title", "supernotesgmtext", "imagelinks", "replace"].includes(spec.key)) return; + + let value = ""; + if(spec.key === "gmnotes") { - sendStyledMessage( - "Convert: Pins Skipped", - `
    ${skipped.map(s => `
  • ${_.escape(s)}
  • `).join("")}
` - ); + 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) -{ + function handlePlace(msg, args) + { if(!args.length) return; @@ -1142,24 +1168,24 @@ function handlePlace(msg, args) for(let i = 0; i < args.length; i++) { - const t = args[i]; - const idx = t.indexOf("|"); - if(idx === -1) continue; + 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"); @@ -1173,14 +1199,14 @@ function handlePlace(msg, args) /* ---------------- Resolve handout ---------------- */ const handouts = findObjs( - { + { _type: "handout", name: handoutName - }); + }); if(!handouts.length) - return sendError(`No handout named "${handoutName}" found (case-sensitive).`); + return sendError(`No handout named "${handoutName}" found (case-sensitive).`); if(handouts.length > 1) - return sendError(`More than one handout named "${handoutName}" exists.`); + return sendError(`More than one handout named "${handoutName}" exists.`); const handout = handouts[0]; const handoutId = handout.id; @@ -1189,7 +1215,7 @@ function handlePlace(msg, args) const pageId = getPageForPlayer(msg.playerid); if(typeof pageId === "undefined") - return sendError("pageId is not defined."); + return sendError("pageId is not defined."); const page = getObj("page", pageId); if(!page) return sendError("Invalid pageId."); @@ -1205,52 +1231,53 @@ function handlePlace(msg, args) /* ---------------- Header extraction ---------------- */ const headerRegex = new RegExp( - `([\\s\\S]*?)<\\/h${headerLevel}>`, - "gi" + `([\\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 - }); - } + let m; + while((m = headerRegex.exec(html)) !== null) + { + headers.push( + { + text: _.unescape(m[1]).trim(), + subLinkType + }); + } } handout.get("notes", html => extractHeaders(html, "headerPlayer")); handout.get("gmnotes", html => extractHeaders(html, "headerGM")); if(!headers.length) - return sendError(`No headers found in handout.`); + 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 ---------------- */ - headers.forEach(h => - { + const burndown = () => { + let h = headers.shift(); + if(h) { const headerText = h.text; const subLinkType = h.subLinkType; @@ -1261,34 +1288,7 @@ function handlePlace(msg, args) if(existing) { - x = existing.get("x"); - y = existing.get("y"); - existing.remove(); - 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++; - } - - created++; - } - - // Two-step creation (same defaults as convert) - const pin = createObj("pin", - { - pageid: pageId, - x: x, - y: y, + existing.set({ link: handoutId, linkType: "handout", subLink: headerText, @@ -1298,29 +1298,58 @@ function handlePlace(msg, args) notesDesynced: false, imageDesynced: false, gmNotesDesynced: false - }); - - if(pin) + }); + replaced++; + } + else { - pin.set( + 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", { - link: handoutId, - linkType: "handout", - subLink: headerText + 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(); - /* ---------------- Report ---------------- */ - sendStyledMessage( - "Place Pins", - `

Handout: ${_.escape(handoutName)}

-
    -
  • Pins created: ${created}
  • -
  • Pins replaced: ${replaced}
  • -
` - ); -} + } @@ -1350,4 +1379,3 @@ function handlePlace(msg, args) }); {try{throw new Error('');}catch(e){API_Meta.PinTool.lineCount=(parseInt(e.stack.split(/\n/)[1].replace(/^.*:(\d+):.*$/,'$1'),10)-API_Meta.PinTool.offset);}} - diff --git a/PinTool/script.json b/PinTool/script.json index 42e65bc27..65d779fcf 100644 --- a/PinTool/script.json +++ b/PinTool/script.json @@ -1,7 +1,7 @@ { "name": "PinTool", "script": "PinTool.js", - "version": "1.0.0", + "version": "1.0.1", "description": "# PinTool\n\nPinTool is a GM-only Roll20 API script for creating, inspecting, converting, and managing **map pins** at scale. It can convert older token-based note workflows with Roll20’s newer map pin system, allowing structured handouts and pins to stay in sync.\n\n---\n\n## Core Capabilities\n\n- Bulk modification of map pin properties\n- Precise targeting of selected pins, all pins on a page, or explicit pin IDs\n- Conversion of legacy note tokens into structured handouts\n- Automatic placement of map pins from handout headers (player and GM)\n- Optional chat display of images referenced in notes\n\n**Base Command:** `!pintool`\n\n---\n\n## Primary Commands\n\n```\n!pintool --set\n!pintool --convert\n!pintool --place\n!pintool --purge\n!pintool --help\n```\n\n- `--set` updates one or more properties across many pins at once.\n- `--convert` extracts data from tokens representing the same character and builds or updates a handout.\n- `--place` scans a handout for headers and creates or replaces pins linked directly to those sections.\n- `--purge` removes related tokens or pins in bulk.\n\n---\n\n## Highlights\n\n- Pins created via `--place` link directly to specific headers in Notes or GM Notes.\n- Existing pins are replaced in-place, preserving their positions.\n- Conversion supports header levels, blockquotes, code blocks, and inline image links.\n- Visibility, scale, links, and sync state can all be controlled programmatically.\n\nDesigned for GMs who want more automated control over pin placement and management.", "authors": "Keith Curtis", "roll20userid": "162065", @@ -11,5 +11,5 @@ "pin": "write" }, "conflicts": [], - "previousversions": [] + "previousversions": ["1.0.0"] } \ No newline at end of file