diff --git a/AutoLinker/1.0.1/AutoLinker.js b/AutoLinker/1.0.1/AutoLinker.js new file mode 100644 index 000000000..badd58c61 --- /dev/null +++ b/AutoLinker/1.0.1/AutoLinker.js @@ -0,0 +1,224 @@ +// 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.1'; + log('-=> AutoLinker v' + version + ' is loaded. Type "!autolinker --help" for examples.'); + //Changelog + //1.0.0 Debut + //1.0.1 Added support for Token Reference shorthand: [npc:name] becomes !tokenref + + 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 "npc": + return `${spell.slice(1).join(":")}`; + 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}`; +case "npc": + 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 index e19a3e274..badd58c61 100644 --- a/AutoLinker/AutoLinker.js +++ b/AutoLinker/AutoLinker.js @@ -8,10 +8,11 @@ API_Meta.AutoLinker={offset:Number.MAX_SAFE_INTEGER,lineCount:-1}; on("ready", () => { 'use strict'; - const version = '1.0.0'; + const version = '1.0.1'; log('-=> AutoLinker v' + version + ' is loaded. Type "!autolinker --help" for examples.'); //Changelog //1.0.0 Debut + //1.0.1 Added support for Token Reference shorthand: [npc:name] becomes !tokenref let eventLockout = false; @@ -55,6 +56,9 @@ const autolink = (str, obj) => { return `${cleanHeader}`; } + + + // ===================================================== // SINGLE WORD MODE (namespace links) // ===================================================== @@ -65,6 +69,8 @@ const autolink = (str, obj) => { return `${spell[1]}`; case "pf2": return `${spell[1]}`; + case "npc": + return `${spell.slice(1).join(":")}`; case "gr": return `${spell[1]}`; case "r": @@ -118,7 +124,9 @@ const autolink = (str, obj) => { return `${text}`; case "pf2": return `${text}`; - default: +case "npc": + return `${text}`; + default: return all; } } diff --git a/AutoLinker/script.json b/AutoLinker/script.json index aea96b8e6..25b1f3314 100644 --- a/AutoLinker/script.json +++ b/AutoLinker/script.json @@ -1,7 +1,7 @@ { "name": "AutoLinker", "script": "AutoLinker.js", - "version": "1.0.0", + "version": "1.0.1", "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", @@ -12,6 +12,6 @@ }, "conflicts": [], "previousversions": [ - "1.0.0" + "1.0.0", "1.0.1" ] } \ No newline at end of file diff --git a/Format Table/1.0.0/FormatTable.js b/Format Table/1.0.0/FormatTable.js new file mode 100644 index 000000000..d2641e749 --- /dev/null +++ b/Format Table/1.0.0/FormatTable.js @@ -0,0 +1,529 @@ +// Script: Format Table +// By: Keith Curtis +// Contact: https://app.roll20.net/users/162065/keithcurtis + +var TableFormatter = (() => { + 'use strict'; + + const version = '1.0.0'; + log('-=> Format Table v' + version + ' is loaded. Use !format-table to call up panel'); + // 1.0.0 Debut + + const SCRIPT = 'TableFormatter'; + + const buttonStyle = "background:#555; color:#eee; width:40%; padding:4px 8px; border: solid 1px #111; border-radius:8px; text-decoration:none; display:inline-block; margin:2px; font-weight:bold; font-size:12px;" + // ------------------------------- + // Styles + // ------------------------------- + const STYLES = { + + "5e": { + table: { + width: "100%", + border: "none", + borderSpacing: "0", + outline: "none", + color: "#111" + }, + tr: { + base: { + width: "100%", + textAlign: "left", + fontFamily: "Verdana, sans-serif", + fontSize: "13px", + border: "0px solid #ffffff" + }, + first: { + fontWeight: "bold", + background: "transparent" + }, + odd: { + background: "transparent" + }, + even: { + background: "#E0E5C1" + } + }, + td: { + paddingTop: "2px", + paddingBottom: "2px", + border: "none", + outline: "none", + textAlign: "left" + } + }, + + "5.5e": { + table: { + width: "100%", + border: "none", + borderSpacing: "0", + outline: "none", + color: "#111" + }, + tr: { + base: { + width: "100%", + textAlign: "left", + fontFamily: "Scala Sans Offc, Verdana, sans-serif", + fontSize: "14px", + border: "0px solid #ffffff" + }, + first: { + fontWeight: "bold", + background: "transparent" + }, + odd: { + background: "#f5f8fa" + }, + even: { + background: "#e1ebf0" + } + }, + td: { + paddingTop: "2px", + paddingBottom: "2px", + border: "none", + outline: "none", + textAlign: "left" + } + }, + +"Wikitable": { + table: { + width: "100%", + borderCollapse: "collapse", + backgroundColor: "#f8f9fa", + color: "#202122", + border: "1px solid #a2a9b1", + margin: "1em 0" + }, + tr: { + base: {}, + first: { + backgroundColor: "#eaecf0", + color: "#202122", + fontWeight: "bold", + textAlign: "center" + }, + odd: { + backgroundColor: "#ffffff" + }, + even: { + backgroundColor: "#f8f9fa" + } + }, + td: { + border: "1px solid #a2a9b1", + padding: "0.2em 0.4em", + textAlign: "left" + } +}, + +"Pathfinder 2": { + table: { + width: "100%", + borderCollapse: "collapse", + fontFamily: "Verdana, sans-serif", + fontSize: "14px", + color: "#111" + }, + tr: { + base: {}, + first: { + background: "#4a0409", + color: "#eee", + fontWeight: "bold", + borderBottom: "2px solid #8a6d3b" + }, + odd: { + background: "#f8f3e5" + }, + even: { + background: "#f1e9cd" + } + }, + td: { + padding: "6px", + border: "1px solid #c2b59b" + } +}, + +"OSR": { + table: { + width: "100%", + borderCollapse: "collapse", + fontFamily: "Times New Roman, serif", + fontSize: "15px", + color: "#000", + border: "none" + }, + tr: { + base: {}, + first: { + fontWeight: "bold", + borderBottom: "2px solid #000" + }, + odd: {}, + even: {} + }, + td: { + padding: "4px", + border: "none" + } +}, + +"DnD 3": { + table: { + width: "100%", + borderCollapse: "collapse", + fontFamily: "Verdana, sans-serif", + fontSize: "13px" + }, + tr: { + base: {}, + first: { + background: "#b7c9e2", + color: "#000", + fontWeight: "bold" + }, + odd: { + background: "#ffffff" + }, + even: { + background: "#eef3fa" + } + }, + td: { + padding: "5px", + border: "1px solid #7f9db9" + } +}, + + + + "Minimal": { + table: { + width: "100%", + margin:"0", + borderCollapse: "collapse" + }, + tr: { + base: {}, + first: { + fontWeight: "bold" + }, + odd: {}, + even: {} + }, + td: { + padding: "4px", + border: "1px solid #ccc" + } + }, + + + "Invisible": { + table: { + width: "100%", + borderCollapse: "collapse", + border: "none" + }, + tr: { + base: {}, + first: {}, + odd: {}, + even: {} + }, + td: { + padding: "6px", + border: "none" + } +}, + + + +"Roll20 Default": { + table: {}, + tr: { + base: {}, + first: {}, + odd: {}, + even: {} + }, + td: {} +} + }; + + // ------------------------------- + // Utilities + // ------------------------------- + + const cssObjToString = (obj) => { + return Object.entries(obj) + .map(([k, v]) => `${k.replace(/[A-Z]/g, m => '-' + m.toLowerCase())}:${v}`) + .join(';'); + }; + + const applyStyle = (existing, styleObj) => { + return cssObjToString(styleObj); + }; + + const findHandout = (input) => { + let handout = getObj('handout', input); + if (handout) return handout; + + input = input.toLowerCase(); + return findObjs({ type: 'handout' }) + .find(h => h.get('name').toLowerCase() === input); + }; + + const sendReport = (msg) => { + let report = `
${msg}
`.replace(/\r\n|\r|\n/g, "").trim(); + sendChat(SCRIPT, + `/w gm ${report}` + ); + }; + + // ------------------------------- + // Core Processing + // ------------------------------- + + const processTables = (html, style) => { + + let tableCount = 0; + + // Match top-level tables only (non-greedy) + return { + html: html.replace(//gi, (tableHTML) => { + + tableCount++; + + let rowIndex = 0; + + // Apply table style + tableHTML = tableHTML.replace(/]*)>/i, (m, attrs) => { + return ``; + }); + + // Process rows + tableHTML = tableHTML.replace(/]*)>([\s\S]*?)<\/tr>/gi, (m, attrs, inner) => { + + let trStyle = { ...style.tr.base }; + + if (rowIndex === 0 && style.tr.first) { + Object.assign(trStyle, style.tr.first); + } else if (rowIndex % 2 === 0 && style.tr.even) { + Object.assign(trStyle, style.tr.even); + } else if (style.tr.odd) { + Object.assign(trStyle, style.tr.odd); + } + + rowIndex++; + + // Process cells + inner = inner.replace(/]*)>([\s\S]*?)<\/td>/gi, (tdMatch, tdAttrs, tdInner) => { + return ``; + }); + + return `${inner}`; + }); + + return tableHTML; + }), + count: tableCount + }; + }; + + // ------------------------------- + // Command Parsing + // ------------------------------- + +const parseArgs = (msg) => { + let args = msg.content.split(/\s+--/).slice(1); + + let out = {}; + + args.forEach(a => { + let [key, val] = a.split('|'); + + if (key) { + out[key.trim().toLowerCase()] = val ? val.trim() : true; + } + }); + + return out; +}; + // ------------------------------- + // Main Handler + // ------------------------------- + +const handleInput = (msg) => { + if (msg.type !== 'api') return; + if (!msg.content.startsWith('!format-table')) return; + + const args = parseArgs(msg); + + // ------------------------------- + // HELP + // ------------------------------- + if (args.help !== undefined) { + + const styles = Object.keys(STYLES).sort(); + const styleList = styles.map(s => `
  • ${s}
  • `).join(''); + + sendReport(` + TableFormatter Help

    + + Purpose
    + Applies a predefined style to all tables in a handout.

    + + Usage
    + !format-table
    + Calls up a control panel to apply styles with a button press.

    + + Macro Syntax
    + !format-table --style|STYLE --handout|HANDOUT

    + + Arguments
    +
      +
    • --style | Style name
    • +
    • --handout | Handout name or ID
    • +
    + + Available Styles +
      + ${styleList} +
    + + Example
    + !format-table --style|5e --handout|Monster Stats

    + + Notes
    + • Formats all tables in the handout
    + • Existing table styles will be replaced
    + • Handout must exist

    + + Open Style Selector + `); + + return; + } + + // ------------------------------- + // BARE COMMAND → UI + // ------------------------------- + if (msg.content.trim() === '!format-table') { + + const styles = Object.keys(STYLES).sort(); + + const styleButtons = styles.map(s => { + const cmd = `!format-table --style|${s} --handout|?{Handout Name or ID}`; + return `${s}`; + }).join(''); + + sendReport(` + Table Formatter

    + Apply a predefined style to all tables in a handout.

    + +
    + ${styleButtons} +
    + +
    + You will be prompted for the handout name or ID.

    + + Help + `); + + return; + } + + // ------------------------------- + // NORMAL EXECUTION + // ------------------------------- + const styleName = args.style; + const handoutInput = args.handout; + + if (!styleName || !handoutInput) { + sendReport(`Missing arguments.
    + Use !format-table --help or run !format-table for UI.`); + return; + } + + const style = STYLES[styleName]; + if (!style) { + sendReport(`Style ${styleName} not found.`); + return; + } + + const handout = findHandout(handoutInput); + if (!handout) { + sendReport(`Handout ${handoutInput} not found.`); + return; + } + + handout.get('notes', (notes) => { + + let result = processTables(notes, style); + + handout.set('notes', result.html); + + sendReport(` + Table Formatting Complete
    + Handout: ${handout.get('name')}
    + Style: ${styleName}
    + Tables Processed: ${result.count} + `); + }); +}; + +const buildMacroLink = () => { + + const styles = Object.keys(STYLES); + + // Build dropdown list + const styleOptions = styles + .map(s => `${s}`) + .join('|'); + + // Confirmation prompt (first gate) + const confirm = `?{Format all tables in a handout using a predefined style?|Yes,continue|No,abort}`; + + const command = + `!format-table ` + + `--style|?{Style|${styleOptions}} ` + + `--handout|?{Handout Name or ID}`; + + // Wrap with confirmation gate + const fullCommand = `${confirm}==Yes,continue?${command}:`; + + return `Format a Table`; +}; + + + + // ------------------------------- + // Init + // ------------------------------- + + const register = () => { + on('chat:message', handleInput); + }; + + return { + register + }; + +})(); + +on('ready', () => { + TableFormatter.register(); +}); \ No newline at end of file diff --git a/Format Table/FormatTable.js b/Format Table/FormatTable.js new file mode 100644 index 000000000..d2641e749 --- /dev/null +++ b/Format Table/FormatTable.js @@ -0,0 +1,529 @@ +// Script: Format Table +// By: Keith Curtis +// Contact: https://app.roll20.net/users/162065/keithcurtis + +var TableFormatter = (() => { + 'use strict'; + + const version = '1.0.0'; + log('-=> Format Table v' + version + ' is loaded. Use !format-table to call up panel'); + // 1.0.0 Debut + + const SCRIPT = 'TableFormatter'; + + const buttonStyle = "background:#555; color:#eee; width:40%; padding:4px 8px; border: solid 1px #111; border-radius:8px; text-decoration:none; display:inline-block; margin:2px; font-weight:bold; font-size:12px;" + // ------------------------------- + // Styles + // ------------------------------- + const STYLES = { + + "5e": { + table: { + width: "100%", + border: "none", + borderSpacing: "0", + outline: "none", + color: "#111" + }, + tr: { + base: { + width: "100%", + textAlign: "left", + fontFamily: "Verdana, sans-serif", + fontSize: "13px", + border: "0px solid #ffffff" + }, + first: { + fontWeight: "bold", + background: "transparent" + }, + odd: { + background: "transparent" + }, + even: { + background: "#E0E5C1" + } + }, + td: { + paddingTop: "2px", + paddingBottom: "2px", + border: "none", + outline: "none", + textAlign: "left" + } + }, + + "5.5e": { + table: { + width: "100%", + border: "none", + borderSpacing: "0", + outline: "none", + color: "#111" + }, + tr: { + base: { + width: "100%", + textAlign: "left", + fontFamily: "Scala Sans Offc, Verdana, sans-serif", + fontSize: "14px", + border: "0px solid #ffffff" + }, + first: { + fontWeight: "bold", + background: "transparent" + }, + odd: { + background: "#f5f8fa" + }, + even: { + background: "#e1ebf0" + } + }, + td: { + paddingTop: "2px", + paddingBottom: "2px", + border: "none", + outline: "none", + textAlign: "left" + } + }, + +"Wikitable": { + table: { + width: "100%", + borderCollapse: "collapse", + backgroundColor: "#f8f9fa", + color: "#202122", + border: "1px solid #a2a9b1", + margin: "1em 0" + }, + tr: { + base: {}, + first: { + backgroundColor: "#eaecf0", + color: "#202122", + fontWeight: "bold", + textAlign: "center" + }, + odd: { + backgroundColor: "#ffffff" + }, + even: { + backgroundColor: "#f8f9fa" + } + }, + td: { + border: "1px solid #a2a9b1", + padding: "0.2em 0.4em", + textAlign: "left" + } +}, + +"Pathfinder 2": { + table: { + width: "100%", + borderCollapse: "collapse", + fontFamily: "Verdana, sans-serif", + fontSize: "14px", + color: "#111" + }, + tr: { + base: {}, + first: { + background: "#4a0409", + color: "#eee", + fontWeight: "bold", + borderBottom: "2px solid #8a6d3b" + }, + odd: { + background: "#f8f3e5" + }, + even: { + background: "#f1e9cd" + } + }, + td: { + padding: "6px", + border: "1px solid #c2b59b" + } +}, + +"OSR": { + table: { + width: "100%", + borderCollapse: "collapse", + fontFamily: "Times New Roman, serif", + fontSize: "15px", + color: "#000", + border: "none" + }, + tr: { + base: {}, + first: { + fontWeight: "bold", + borderBottom: "2px solid #000" + }, + odd: {}, + even: {} + }, + td: { + padding: "4px", + border: "none" + } +}, + +"DnD 3": { + table: { + width: "100%", + borderCollapse: "collapse", + fontFamily: "Verdana, sans-serif", + fontSize: "13px" + }, + tr: { + base: {}, + first: { + background: "#b7c9e2", + color: "#000", + fontWeight: "bold" + }, + odd: { + background: "#ffffff" + }, + even: { + background: "#eef3fa" + } + }, + td: { + padding: "5px", + border: "1px solid #7f9db9" + } +}, + + + + "Minimal": { + table: { + width: "100%", + margin:"0", + borderCollapse: "collapse" + }, + tr: { + base: {}, + first: { + fontWeight: "bold" + }, + odd: {}, + even: {} + }, + td: { + padding: "4px", + border: "1px solid #ccc" + } + }, + + + "Invisible": { + table: { + width: "100%", + borderCollapse: "collapse", + border: "none" + }, + tr: { + base: {}, + first: {}, + odd: {}, + even: {} + }, + td: { + padding: "6px", + border: "none" + } +}, + + + +"Roll20 Default": { + table: {}, + tr: { + base: {}, + first: {}, + odd: {}, + even: {} + }, + td: {} +} + }; + + // ------------------------------- + // Utilities + // ------------------------------- + + const cssObjToString = (obj) => { + return Object.entries(obj) + .map(([k, v]) => `${k.replace(/[A-Z]/g, m => '-' + m.toLowerCase())}:${v}`) + .join(';'); + }; + + const applyStyle = (existing, styleObj) => { + return cssObjToString(styleObj); + }; + + const findHandout = (input) => { + let handout = getObj('handout', input); + if (handout) return handout; + + input = input.toLowerCase(); + return findObjs({ type: 'handout' }) + .find(h => h.get('name').toLowerCase() === input); + }; + + const sendReport = (msg) => { + let report = `
    ${msg}
    `.replace(/\r\n|\r|\n/g, "").trim(); + sendChat(SCRIPT, + `/w gm ${report}` + ); + }; + + // ------------------------------- + // Core Processing + // ------------------------------- + + const processTables = (html, style) => { + + let tableCount = 0; + + // Match top-level tables only (non-greedy) + return { + html: html.replace(//gi, (tableHTML) => { + + tableCount++; + + let rowIndex = 0; + + // Apply table style + tableHTML = tableHTML.replace(/]*)>/i, (m, attrs) => { + return `
    ${tdInner}
    `; + }); + + // Process rows + tableHTML = tableHTML.replace(/]*)>([\s\S]*?)<\/tr>/gi, (m, attrs, inner) => { + + let trStyle = { ...style.tr.base }; + + if (rowIndex === 0 && style.tr.first) { + Object.assign(trStyle, style.tr.first); + } else if (rowIndex % 2 === 0 && style.tr.even) { + Object.assign(trStyle, style.tr.even); + } else if (style.tr.odd) { + Object.assign(trStyle, style.tr.odd); + } + + rowIndex++; + + // Process cells + inner = inner.replace(/]*)>([\s\S]*?)<\/td>/gi, (tdMatch, tdAttrs, tdInner) => { + return ``; + }); + + return `${inner}`; + }); + + return tableHTML; + }), + count: tableCount + }; + }; + + // ------------------------------- + // Command Parsing + // ------------------------------- + +const parseArgs = (msg) => { + let args = msg.content.split(/\s+--/).slice(1); + + let out = {}; + + args.forEach(a => { + let [key, val] = a.split('|'); + + if (key) { + out[key.trim().toLowerCase()] = val ? val.trim() : true; + } + }); + + return out; +}; + // ------------------------------- + // Main Handler + // ------------------------------- + +const handleInput = (msg) => { + if (msg.type !== 'api') return; + if (!msg.content.startsWith('!format-table')) return; + + const args = parseArgs(msg); + + // ------------------------------- + // HELP + // ------------------------------- + if (args.help !== undefined) { + + const styles = Object.keys(STYLES).sort(); + const styleList = styles.map(s => `
  • ${s}
  • `).join(''); + + sendReport(` + TableFormatter Help

    + + Purpose
    + Applies a predefined style to all tables in a handout.

    + + Usage
    + !format-table
    + Calls up a control panel to apply styles with a button press.

    + + Macro Syntax
    + !format-table --style|STYLE --handout|HANDOUT

    + + Arguments
    +
      +
    • --style | Style name
    • +
    • --handout | Handout name or ID
    • +
    + + Available Styles +
      + ${styleList} +
    + + Example
    + !format-table --style|5e --handout|Monster Stats

    + + Notes
    + • Formats all tables in the handout
    + • Existing table styles will be replaced
    + • Handout must exist

    + + Open Style Selector + `); + + return; + } + + // ------------------------------- + // BARE COMMAND → UI + // ------------------------------- + if (msg.content.trim() === '!format-table') { + + const styles = Object.keys(STYLES).sort(); + + const styleButtons = styles.map(s => { + const cmd = `!format-table --style|${s} --handout|?{Handout Name or ID}`; + return `${s}`; + }).join(''); + + sendReport(` + Table Formatter

    + Apply a predefined style to all tables in a handout.

    + +
    + ${styleButtons} +
    + +
    + You will be prompted for the handout name or ID.

    + + Help + `); + + return; + } + + // ------------------------------- + // NORMAL EXECUTION + // ------------------------------- + const styleName = args.style; + const handoutInput = args.handout; + + if (!styleName || !handoutInput) { + sendReport(`Missing arguments.
    + Use !format-table --help or run !format-table for UI.`); + return; + } + + const style = STYLES[styleName]; + if (!style) { + sendReport(`Style ${styleName} not found.`); + return; + } + + const handout = findHandout(handoutInput); + if (!handout) { + sendReport(`Handout ${handoutInput} not found.`); + return; + } + + handout.get('notes', (notes) => { + + let result = processTables(notes, style); + + handout.set('notes', result.html); + + sendReport(` + Table Formatting Complete
    + Handout: ${handout.get('name')}
    + Style: ${styleName}
    + Tables Processed: ${result.count} + `); + }); +}; + +const buildMacroLink = () => { + + const styles = Object.keys(STYLES); + + // Build dropdown list + const styleOptions = styles + .map(s => `${s}`) + .join('|'); + + // Confirmation prompt (first gate) + const confirm = `?{Format all tables in a handout using a predefined style?|Yes,continue|No,abort}`; + + const command = + `!format-table ` + + `--style|?{Style|${styleOptions}} ` + + `--handout|?{Handout Name or ID}`; + + // Wrap with confirmation gate + const fullCommand = `${confirm}==Yes,continue?${command}:`; + + return `Format a Table`; +}; + + + + // ------------------------------- + // Init + // ------------------------------- + + const register = () => { + on('chat:message', handleInput); + }; + + return { + register + }; + +})(); + +on('ready', () => { + TableFormatter.register(); +}); \ No newline at end of file diff --git a/Format Table/readme.md b/Format Table/readme.md new file mode 100644 index 000000000..98b116680 --- /dev/null +++ b/Format Table/readme.md @@ -0,0 +1,107 @@ +# Format Table + +**Format Table** is a Roll20 API script that applies clean, consistent styling to all HTML tables inside a handout. It supports multiple prebuilt style presets tailored for popular tabletop systems and formatting conventions. + +--- + +## Features + +- Apply predefined visual styles to handout tables +- One-command formatting for all tables in a handout +- Interactive UI for easy style selection +- Supports multiple RPG systems and generic formats + +--- + +## Usage + +### Basic Command (UI Mode) + +`!format-table` + +Opens an interactive control panel with buttons for each available style. You will be prompted to enter a handout name or ID. + +--- + +### Direct Command + +!format-table --style|STYLE --handout|HANDOUT + +#### Arguments + +- `--style` + Name of the style to apply + +- `--handout` + Handout name (case-insensitive) or ID + +--- + +### Example + +!format-table --style|5e --handout|Monster Stats + +--- + +### Help Command + +!format-table --help + +Displays usage instructions and a list of available styles. + +--- + +## Available Styles + +The script includes the following built-in styles: + +- `5e` +- `5.5e` +- `Wikitable` +- `Pathfinder 2` +- `OSR` +- `DnD 3` +- `Minimal` +- `Invisible` +- `Roll20 Default` + +Each style defines: + +- Table layout +- Row striping (odd/even) +- Header row formatting +- Cell padding and borders +- Font family and size + +--- + +## Behavior Details + +- Processes **all `
    ${tdInner}
    ` elements** in the handout notes. +- Alternates row styling automatically (even/odd). +- First row is treated as a header when defined by the selected style. + +--- + +## Notes & Limitations + +- Existing table formatting will be overwritten. +- Only ` + + +`; + } + + const buttons = `
    ` elements are styled (`` is not explicitly handled). +- Nested tables may not be processed correctly. +- Requires valid HTML tables in the handout notes. +- Large handouts may take longer to process. + +--- + +## How It Works + +- Parses handout HTML using regex to locate `` elements. +- Applies style definitions from a centralized `STYLES` object. +- Converts JavaScript style objects into inline CSS. +- Rewrites table markup with consistent formatting. + +--- + +## Modification + +If you import instead of install, so you have access to the code, you can add new styles to the `STYLES` object \ No newline at end of file diff --git a/Format Table/script.json b/Format Table/script.json new file mode 100644 index 000000000..5d995cd52 --- /dev/null +++ b/Format Table/script.json @@ -0,0 +1,14 @@ +{ + "name": "Format Table", + "script": "FormatTable.js", + "version": "1.0.0", + "description": "Displays a chat menu for auto-formatting handout tables with a variety of presets resembling those used by popular game systems. Supports 5.5e, 5e, DnD 3, Invisible, Minimal, OSR, Pathfinder 2, Roll20 Default, Wikitable", + "authors": "Keith Curtis", + "roll20userid": "162065", + "modifies": { + "handout": "read,write" + }, + "dependencies": [], + "conflicts": [], + "previousversions": ["1.0.0"] +} \ No newline at end of file diff --git a/ImageEditor/1.0.0/ImageEditor.js b/ImageEditor/1.0.0/ImageEditor.js new file mode 100644 index 000000000..923346370 --- /dev/null +++ b/ImageEditor/1.0.0/ImageEditor.js @@ -0,0 +1,788 @@ +// Script: Image Editor +// By: Keith Curtis +// Contact: https://app.roll20.net/users/162065/keithcurtis +const imageeditor = (() => { + +on("ready", () => { + 'use strict'; + + const version = '1.0.0'; + log('-=> Image Editor v' + version + ' is loaded. Use !imageeditor to create interface'); + // 1.0.0 Debut + + + // ================================================== + // HELP HANDOUT + // ================================================== + const HELP_NAME = 'Help: Image Editor'; + const HELP_AVATAR = 'https://files.d20.io/images/470559564/QxDbBYEhr6jLMSpm0x42lg/original.png?1767857147'; + const HELP_TEXT = ` +

    Image Editor Guide

    +

    +The Image Editor allows you to select any handout that contains images and modify the style, layout, and properties of those images without manually editing HTML. Changes are written directly back to the handout's notes. +

    + +

    Getting Started

    +

    Type !imageeditor in chat to open the Image Editor interface. The editor opens as a handout called Image Editor in your journal. Only handouts that contain at least one image will appear in the chooser.

    + +

    Choosing a Handout

    +

    Click the Choose Handout button in the top right of the editor. A dropdown will appear listing all handouts that contain images, in alphabetical order. Selecting one will load its images into the editor.

    +

    The name of the currently selected handout appears as a link in the header. Below it, if the currently selected image is preceded by a header in the handout, that header will appear as a secondary link which jumps directly to that section of the handout.

    + +

    Thumbnails Panel

    +

    The left panel shows thumbnails of every image found in the selected handout. Click any thumbnail to load that image into the preview panel and populate the properties panel with its current values. The title of the image, if set, appears below each thumbnail.

    + +

    Preview Panel

    +

    The center panel shows a large preview of the currently selected image. At the top of the preview panel is a navigation bar with left and right arrow buttons for moving to the previous or next image without scrolling the thumbnail list. The small thumbnails beside the arrows show the adjacent images and are also clickable.

    + +

    Properties Panel

    +

    The right panel shows the editable properties of the currently selected image. Click any value to open a prompt where you can enter a new value. Leaving the prompt blank will remove that property from the image entirely.

    + +

    title

    +

    Sets the title attribute of the image tag. This text appears as a tooltip when the user hovers over the image.

    + +

    url

    +

    Sets the src attribute, replacing the image with a different one at the given URL.

    + +

    layout

    +

    Applies a float-based layout preset to the image. Options are:

    +
      +
    • left — floats the image to the left, text wraps around the right.
    • +
    • right — floats the image to the right, text wraps around the left.
    • +
    • center — displays the image as a centered block.
    • +
    • none — removes float and display properties.
    • +
    + +

    width / height

    +

    Sets the width or height of the image. Values must be in pixels (e.g. 200px) or percent (e.g. 50%).

    + +

    margin

    +

    Sets the margin around the image. Accepts 1 to 4 values in pixels or percent, space-separated, following standard CSS margin shorthand (e.g. 8px 16px).

    + +

    border-radius

    +

    Rounds the corners of the image. Value must be in pixels or percent (e.g. 8px).

    + +

    Presets

    +

    Below the properties are quick-apply preset buttons. Clicking a preset merges a predefined set of style values into the image's existing styles, preserving properties like border-radius that are not part of the preset.

    +
      +
    • Left 30% / 50% / 60% — floats the image left at the given width with a small right margin.
    • +
    • Right 30% / 50% / 60% — floats the image right at the given width with a small left margin.
    • +
    • Center — displays the image as a full-width centered block.
    • +
    • Clear — removes all inline style information from the image tag, returning it to its unstyled default.
    • +
    + +

    Commands

    +
      +
    • !imageeditor — Opens or refreshes the Image Editor interface.
    • +
    • !imageeditor --help — Creates or updates this help handout and whispers you a link to it.
    • +
    + +

    Notes

    +
      +
    • The Image Editor cannot be used to edit its own handout.
    • +
    • Only images in the notes field of a handout are visible to the editor. Images in gmnotes are not shown.
    • +
    • Style changes are written directly to the handout HTML. Always keep a backup of important handout content.
    • +
    +`; + + // ================================================== + // STATE + // ================================================== + const checkInstall = () => { + state.ImageEditor = state.ImageEditor || { + handoutId: null, + selectedIndex: 0, + // cachedHandoutOptions: array of "Name,id" strings for the picker. + // Rebuilt only on --choose and --refresh, not on every render. + cachedHandoutOptions: null + }; + // Remove legacy fields that should not be in persistent state. + if (state.ImageEditor.hasOwnProperty('currentImages')) { + delete state.ImageEditor.currentImages; + } + if (state.ImageEditor.hasOwnProperty('editorHandoutId')) { + delete state.ImageEditor.editorHandoutId; + } + }; + + // ================================================== + // CONFIG + // ================================================== + const Config = { + editorName: 'Image Editor', + + properties: { + width: { type: 'size' }, + 'max-width': { type: 'size' }, + height: { type: 'size' }, + 'max-height': { type: 'size' }, + 'border-radius': { type: 'size' }, + + layout: { + type: 'enum', + values: ['left', 'right', 'center', 'none'] + }, + + margin: { type: 'margin' }, + + title: { type: 'string', attribute: true }, + url: { type: 'string', attribute: true } + }, + + presets: [ + { label: 'Left 30%', styles: { width: '30%', float: 'left', display: 'inline', margin: '0 8px 8px 0' } }, + { label: 'Right 30%', styles: { width: '30%', float: 'right', display: 'inline', margin: '0 0 8px 8px' } }, + { label: 'Left 50%', styles: { width: '50%', float: 'left', display: 'inline', margin: '0 8px 8px 0' } }, + { label: 'Right 50%', styles: { width: '50%', float: 'right', display: 'inline', margin: '0 0 8px 8px' } }, + { label: 'Left 60%', styles: { width: '60%', float: 'left', display: 'inline', margin: '0 8px 8px 0' } }, + { label: 'Right 60%', styles: { width: '60%', float: 'right', display: 'inline', margin: '0 0 8px 8px' } }, + { label: 'Center', styles: { width: '100%', float: 'none', display: 'block', margin: '0 auto' } }, + { label: 'Clear', styles: null } + ] + }; + + // ================================================== + // CSS (CENTRALIZED) + // ================================================== + const CSS = { + header: 'font-weight:bold; font-size:18px; padding:8px; color:#ddd;', + headerRow: 'width:100%; background:#000; border:none; table-layout:fixed;', + headerCell: 'vertical-align:middle; border:none; background:#000; padding:0 8px;', + right: 'text-align:right;', + center: 'text-align:center; color:#ddd; font-weight:bold; font-size:18px;', + + table: 'width:100%; background:#000; color:#ccc; border:none; table-layout:fixed;', + + td: 'vertical-align:top; padding:10px;', + tdThumbnail: 'width:205px; vertical-align:top; padding:10px; text-align:center;', + tdPreview: 'vertical-align:top; padding:10px;', + tdControl: 'width:205px; vertical-align:top; padding:10px;', + + thumb: 'display:block; margin:8px auto 2px auto; width:125px; border:1px solid #444; border-radius:8px; background:transparent;', + preview: 'max-width:100%; max-height:300px; border:none; background:#111; padding:4px;', + + handoutButton: 'text-decoration:none; color:#6ca0ff; background:#222; border:1px solid #444; padding:4px 8px; border-radius:3px; cursor:pointer; font-size:14px;', + button: 'text-decoration:none; color:#6ca0ff; cursor:pointer;', + + label: 'font-weight:bold; color:#aaa;', + value: 'color:#6ca0ff;', + muted: 'color:#666;', + + panelHeader: 'font-weight:bold; color:#ddd; margin-bottom:8px; border-bottom:1px solid #444; padding-bottom:4px;', + + thumbnailPanel:'width:200px; max-height:500px; overflow-y:auto; background:#000; border:none; padding:8px; text-align:center;', + previewPanel: 'background:#000; border:none; padding:8px; text-align:center;', + controlPanel: 'width:200px; max-height:500px; overflow-y:auto; background:#000; border:none; padding:8px;', + previewHeader: 'width:100%; overflow:hidden; border-bottom:1px solid #444; padding-bottom:4px; margin-bottom:8px;', + navButton: 'text-decoration:none; color:#ddd; font-weight:bold; font-size:16px; padding:0 6px; background:#222; border:1px solid #444; border-radius:3px;', + navThumb: 'max-height:30px; border:1px solid #444; border-radius:3px; vertical-align:middle; margin:0 2px;', + + handoutLink: 'text-decoration:none; color:#fff; font-weight:bold; font-size:14px;', + headerLink: 'text-decoration:none; color:#aad4f5; font-weight:normal; font-size:11px; display:block; margin-top:3px;', + + // Chat launch message (sent when bare !imageeditor is typed) + launchBox: 'background:#111; border:1px solid #444; border-radius:4px; padding:10px 14px; font-family:sans-serif;', + launchTitle: 'font-size:15px; font-weight:bold; color:#ddd; margin-bottom:6px;', + launchLink: 'text-decoration:none; color:#6ca0ff; background:#222; border:1px solid #444; padding:5px 10px; border-radius:3px; font-size:13px; font-weight:bold;', + + // Help whisper box — same family as launchBox + helpLink: 'color:#9fd3ff; font-weight:bold; text-decoration:none;' + }; + + // ================================================== + // UTILITIES + // ================================================== + const Utils = { + + send: (msg) => sendChat('ImageEditor', msg), + whisper: (who, msg) => sendChat('ImageEditor', `/w "${who.replace(/ \(GM\)$/,'')}" ${msg}`), + + buildEnumQuery: (title, values, current) => { + const opts = values.map(v => `${v}${v === current ? '*' : ''}`); + return `?{${title}|${opts.join('|')}}`; + }, + + buildSizeQuery: (title, current) => `?{${title} (px or %)|${current || ''}}`, + buildMarginQuery: (current) => `?{Margin (1-4 values px or %)|${current || ''}}`, + + // ------------------------------------------------------------------ + // rebuildHandoutCache + // ------------------------------------------------------------------ + // Asynchronously scans all handouts for images and stores the result + // in state.ImageEditor.cachedHandoutOptions as an array of "Name,id" + // strings. Calls callback() when done (no arguments). + // + // Call this ONLY on --choose and --refresh. All other operations + // must use the cache directly — never call this on every render. + // ------------------------------------------------------------------ + rebuildHandoutCache: (callback) => { + const handouts = findObjs({ type: 'handout' }) + .sort((a, b) => a.get('name').localeCompare(b.get('name'))); + + if (!handouts.length) { + state.ImageEditor.cachedHandoutOptions = []; + callback(); + return; + } + + const results = []; + let remaining = handouts.length; + + handouts.forEach(h => { + h.get('notes', text => { + const hasImage = /]*>/i.test(text || ''); + if (hasImage) results.push(h); + remaining--; + if (remaining === 0) { + state.ImageEditor.cachedHandoutOptions = + results.map(r => `${r.get('name')},${r.id}`); + callback(); + } + }); + }); + }, + + // Return the cached picker options, or an empty array if not yet built. + getCachedOptions: () => state.ImageEditor.cachedHandoutOptions || [], + + // Always look up the editor handout by name. This means a rename or + // deletion is handled gracefully — the script never holds a stale ID. + // findObjs is an in-memory index lookup and is fast enough to call + // on every command without caching. + getEditorHandout: () => { + const existing = findObjs({ type: 'handout', name: Config.editorName })[0]; + if (existing) return existing; + + // Not found — create it fresh. + log(`[ImageEditor] "${Config.editorName}" handout not found — creating.`); + return createObj('handout', { + name: Config.editorName, + inplayerjournals: 'all' + }); + }, + + // ------------------------------------------------------------------ + // findPrecedingHeader + // ------------------------------------------------------------------ + // Scans the handout HTML for the nearest h1-h4 that appears before + // the Nth tag (0-based). Returns { text, level } or null. + // ------------------------------------------------------------------ + findPrecedingHeader: (html, imgIndex) => { + + // Step 1: find the character offset of the target tag. + const imgRe = /]*>/gi; + let count = 0; + let imgPos = -1; + let m; + + while ((m = imgRe.exec(html)) !== null) { + if (count === imgIndex) { imgPos = m.index; break; } + count++; + } + + if (imgPos === -1) return null; + + // Step 2: find the last h1-h4 whose opening tag starts before imgPos. + // Work on only the substring before the image for efficiency. + const before = html.slice(0, imgPos); + const headRe = /]*>([\s\S]*?)<\/h\1>/gi; + let lastHeader = null; + let hm; + + while ((hm = headRe.exec(before)) !== null) { + const text = hm[2].replace(/<[^>]+>/g, '').trim(); + if (text) lastHeader = { text, level: parseInt(hm[1], 10) }; + } + + return lastHeader; + } + }; + + // ================================================== + // COMMAND PARSER + // ================================================== + const parseArgs = (content) => { + const tokens = content.split(/\s+--/).slice(1); + const args = {}; + + tokens.forEach(t => { + const [key, ...rest] = t.split(/\s+/); + const body = rest.join(' '); + + if (key === 'set') { + const [prop, ...valParts] = body.split('|'); + const value = valParts.join('|').trim(); + args.set = args.set || []; + args.set.push({ property: prop.trim(), value }); + } else { + args[key] = body.trim(); + } + }); + + return args; + }; + + // ================================================== + // STYLE ENGINE + // ================================================== + const StyleEngine = { + + parse: (str) => { + const o = {}; + if (!str) return o; + str.split(';').forEach(p => { + const idx = p.indexOf(':'); + if (idx === -1) return; + const k = p.slice(0, idx).trim(); + const v = p.slice(idx + 1).trim(); + if (k && v) o[k] = v; + }); + return o; + }, + + serialize: (o) => + Object.entries(o).map(([k, v]) => `${k}:${v}`).join('; '), + + validateSize: (v) => + /^\d+(px|%)$/.test(v) ? v : null, + + validateMargin: (v) => { + v = v.trim().replace(/\s+/g, ' '); + return /^(\d+(px|%))( \d+(px|%)){0,3}$/.test(v) ? v : null; + }, + + applyLayout: (style, layout) => { + delete style.float; + delete style.display; + + if (layout === 'left') { style.float = 'left'; style.display = 'inline'; style.margin = '0'; } + if (layout === 'right') { style.float = 'right'; style.display = 'inline'; style.margin = '0'; } + + if (layout === 'center') { + style.float = 'none'; + style.display = 'block'; + if (style.margin) { + const p = style.margin.split(' '); + if (p.length === 1) style.margin = `${p[0]} auto`; + if (p.length === 2) style.margin = `${p[0]} auto`; + if (p.length === 3) style.margin = `${p[0]} auto ${p[2]}`; + if (p.length === 4) style.margin = `${p[0]} auto ${p[2]} auto`; + } else { + style.margin = '0 auto'; + } + } + return style; + } + }; + + // ================================================== + // IMAGE PARSER + // ================================================== + const Parser = { + + extractImages: (html) => { + const matches = html.match(/]*>/gi) || []; + return matches.map(tag => { + const get = (a) => { + const m = tag.match(new RegExp(`${a}="([^"]*)"`, 'i')); + return m ? m[1] : ''; + }; + return { tag, src: get('src'), style: get('style'), title: get('title') }; + }); + }, + + // Replace the first occurrence of oldTag in html using a literal + // string search (indexOf + slice) rather than .replace(), which would + // misinterpret regex special characters in image src URLs. + replaceImage: (html, oldTag, newTag) => { + const pos = html.indexOf(oldTag); + if (pos === -1) return html; + return html.slice(0, pos) + newTag + html.slice(pos + oldTag.length); + }, + + rebuildTag: (img, styleStr) => { + let t = img.tag + .replace(/\sstyle="[^"]*"/i, '') + .replace(/\stitle="[^"]*"/i, '') + .replace(/\ssrc="[^"]*"/i, ''); + return t.replace(' { + + const handoutId = state.ImageEditor.handoutId; + + let centreContent; + if (handoutName) { + const handoutUrl = `http://journal.roll20.net/handout/${handoutId}`; + const handoutLink = `${handoutName}`; + const deepLink = headerInfo + ? `${headerInfo.text}` + : ''; + centreContent = handoutLink + deepLink; + } else { + centreContent = `No Handout Selected`; + } + + const pickerQuery = (handoutOptions && handoutOptions.length) + ? `?{Select Handout|${handoutOptions.join('|')}}` + : '?{No handouts with images found|}'; + + return ` +
    + + + + + +
    +
    Image Editor
    +
    + ${centreContent} + + + Choose Handout + + + ? + +
    `; + }, + + thumbs: (images) => + images.map((i, idx) => ` + + + +
    ${i.title || ''}
    + `).join(''), + + controls: (img) => { + + const style = StyleEngine.parse(img.style); + + const control = (name, val, query) => + `
    + ${name}: + + ${val || '—'} + +
    `; + + const propOrder = ['title', 'url', 'layout', 'width', 'height', 'margin', 'border-radius']; + + const propertyControls = propOrder.map(p => { + const conf = Config.properties[p]; + if (!conf) return ''; + + let val = (p === 'title') ? img.title : style[p]; + val = val || '—'; + + let query = ''; + if (conf.type === 'enum') query = Utils.buildEnumQuery(p, conf.values, val === '—' ? '' : val); + if (conf.type === 'size') query = Utils.buildSizeQuery(p, val === '—' ? '' : val); + if (conf.type === 'margin') query = Utils.buildMarginQuery(val === '—' ? '' : val); + if (conf.type === 'string') query = `?{${p}|${val === '—' ? '' : val}}`; + + return control(p, val, query); + }).join(''); + + const presetButtons = Config.presets.map(p => + ` + ${p.label} + ` + ).join(' '); + + return propertyControls + ` +
    +
    Presets
    + ${presetButtons} +
    `; + }, + + render: (handout, images, handoutOptions, headerInfo) => { + + // Clamp idx to valid range — state may hold a stale index from a + // previous session with more images than the current array has. + const idx = images.length > 0 + ? Math.max(0, Math.min(state.ImageEditor.selectedIndex || 0, images.length - 1)) + : 0; + const img = images[idx] || null; + + return ` +
    + ${Renderer.header(handout.get('name'), handoutOptions, headerInfo)} + + + + + + +
    +
    +
    Thumbnails
    + ${Renderer.thumbs(images)} +
    +
    +
    +
    + + ${img && idx > 0 + ? `` + : ``} + + + ${img && idx < images.length - 1 + ? `` + : ``} + + Image Preview +
    + ${img + ? `` + : `
    No image selected
    `} +
    +
    +
    +
    Properties
    + ${img ? Renderer.controls(img) : '
    No image selected
    '} +
    +
    +
    `; + } + }; + + // ================================================== + // RENDER HELPERS + // ================================================== + // Central render call used by all command paths. + // Uses the cached handout options — never rebuilds the list. + const renderEditor = (handout, notes, images) => { + const editor = Utils.getEditorHandout(); + const options = Utils.getCachedOptions(); + const idx = state.ImageEditor.selectedIndex || 0; + const headerInfo = Utils.findPrecedingHeader(notes, idx); + editor.set('notes', Renderer.render(handout, images, options, headerInfo)); + }; + + // Render the empty-state panel (no handout chosen yet) into the editor + // handout. Called on script load and whenever the editor needs to show + // its initial UI so the GM has something to interact with. + const renderEmptyPanel = () => { + const editor = Utils.getEditorHandout(); + editor.set('notes', Renderer.render({ get: () => null }, [], Utils.getCachedOptions(), null)); + }; + + // ================================================== + // MAIN + // ================================================== + const handleInput = (msg) => { + + if (msg.type !== 'api') return; + if (!msg.content.startsWith('!imageeditor')) return; + + // ------------------------------------------------------------------ + // Bare command: !imageeditor (no sub-command) + // Ensure the editor handout exists, then whisper a styled clickable + // link to it so the GM can open it without hunting through the journal. + // ------------------------------------------------------------------ + const trimmed = msg.content.trim(); + if (trimmed === '!imageeditor') { + const editor = Utils.getEditorHandout(); + const url = `http://journal.roll20.net/handout/${editor.id}`; + Utils.whisper(msg.who, + `
    ` + + `
    Image Editor
    ` + + `Open Image Editor` + + `
    ` + ); + // Also ensure the handout has content — renders the empty-state + // panel if the handout was just created or was previously blank. + editor.get('notes', notes => { + if (!notes || !notes.trim()) renderEmptyPanel(); + }); + return; + } + + // ------------------------------------------------------------------ + // --help: create or update the Help: Image Editor handout, whisper link. + // ------------------------------------------------------------------ + if (trimmed === '!imageeditor --help') { + let helpHandout = findObjs({ type: 'handout', name: HELP_NAME })[0]; + if (!helpHandout) { + helpHandout = createObj('handout', { + name: HELP_NAME, + archived: false, + avatar: HELP_AVATAR + }); + } + helpHandout.set('avatar', HELP_AVATAR); + helpHandout.set('notes', HELP_TEXT); + const helpUrl = `http://journal.roll20.net/handout/${helpHandout.id}`; + const helpBox = + `
    ` + + `
    Image Editor Help
    ` + + `Open Help Handout` + + `
    `; + sendChat('ImageEditor', `/w gm ${helpBox}`, null, { noarchive: true }); + return; + } + + const args = parseArgs(msg.content); + const editor = Utils.getEditorHandout(); + + // ------------------------------------------------------------------ + // --choose: set new target handout, rebuild the picker cache, render. + // This is the ONLY path that calls rebuildHandoutCache. + // ------------------------------------------------------------------ + if (args.choose) { + state.ImageEditor.handoutId = args.choose; + state.ImageEditor.selectedIndex = 0; + + const handout = getObj('handout', args.choose); + if (!handout) { + editor.set('notes', Renderer.render({ get: () => null }, [], Utils.getCachedOptions(), null)); + return; + } + + Utils.rebuildHandoutCache(() => { + handout.get('notes', notes => { + const images = Parser.extractImages(notes); + if (!images.length) { + editor.set('notes', + Renderer.header(handout.get('name'), Utils.getCachedOptions(), null) + + '

    No images found in this handout.

    ' + ); + return; + } + renderEditor(handout, notes, images); + }); + }); + return; + } + + // ------------------------------------------------------------------ + // All other commands operate on the already-chosen handout. + // They use the cached picker list and never scan all handouts. + // ------------------------------------------------------------------ + const handout = getObj('handout', state.ImageEditor.handoutId); + + if (!handout) { + editor.set('notes', Renderer.render({ get: () => null }, [], Utils.getCachedOptions(), null)); + return; + } + + if (handout.get('name') === Config.editorName) { + Utils.whisper(msg.who, 'Cannot edit images in the Image Editor handout.'); + return; + } + + handout.get('notes', notes => { + + let images = Parser.extractImages(notes); + + if (!images.length) { + editor.set('notes', + Renderer.header(handout.get('name'), Utils.getCachedOptions(), null) + + '

    No images found in this handout.

    ' + ); + return; + } + + // --select + if (args.select !== undefined) { + state.ImageEditor.selectedIndex = Math.max(0, + Math.min(images.length - 1, parseInt(args.select))); + } + + let img = images[state.ImageEditor.selectedIndex]; + let style = StyleEngine.parse(img.style); + + // --set + if (args.set) { + args.set.forEach(s => { + const conf = Config.properties[s.property]; + if (!conf) return; + const value = s.value.trim(); + + if (!value) { + if (conf.type === 'string') { img.title = ''; } + else { delete style[s.property]; } + if (s.property === 'layout') style = StyleEngine.applyLayout(style, 'none'); + return; + } + if (conf.type === 'size') { const v = StyleEngine.validateSize(value); if (v) style[s.property] = v; } + if (conf.type === 'margin') { const v = StyleEngine.validateMargin(value); if (v) style.margin = v; } + if (conf.type === 'enum') { + if (conf.values.includes(value) && s.property === 'layout') { + style = StyleEngine.applyLayout(style, value); + style.layout = value; + } + } + if (conf.type === 'string') { + if (s.property === 'url') img.src = value; + else img.title = value; + } + }); + + const newTag = Parser.rebuildTag(img, StyleEngine.serialize(style)); + notes = Parser.replaceImage(notes, img.tag, newTag); + handout.set('notes', notes); + images = Parser.extractImages(notes); + } + + // Re-fetch img after possible --set update + img = images[state.ImageEditor.selectedIndex]; + style = StyleEngine.parse(img.style); + + // --preset + if (args.preset) { + try { + const presetStyles = JSON.parse(decodeURIComponent(args.preset)); + if (presetStyles === null) { + style = {}; + } else { + Object.assign(style, presetStyles); + } + } catch(e) { + log('ImageEditor: failed to parse preset — ' + e); + } + const newTag = Parser.rebuildTag(img, StyleEngine.serialize(style)); + notes = Parser.replaceImage(notes, img.tag, newTag); + handout.set('notes', notes); + images = Parser.extractImages(notes); + } + + renderEditor(handout, notes, images); + }); + }; + + // ================================================== + checkInstall(); + on('chat:message', handleInput); + + // Prime the cache on script load, then render the empty-state panel so + // the handout has content immediately — even if it was just created. + Utils.rebuildHandoutCache(() => { + log('-=> Image Editor: handout cache primed (' + + (state.ImageEditor.cachedHandoutOptions || []).length + ' handouts with images).'); + // Only write the empty panel if the handout is genuinely blank — + // don't overwrite a valid session that survived a sandbox restart. + const editor = Utils.getEditorHandout(); + editor.get('notes', notes => { + if (!notes || !notes.trim()) renderEmptyPanel(); + }); + }); + +}); + +})(); + diff --git a/ImageEditor/ImageEditor.js b/ImageEditor/ImageEditor.js new file mode 100644 index 000000000..923346370 --- /dev/null +++ b/ImageEditor/ImageEditor.js @@ -0,0 +1,788 @@ +// Script: Image Editor +// By: Keith Curtis +// Contact: https://app.roll20.net/users/162065/keithcurtis +const imageeditor = (() => { + +on("ready", () => { + 'use strict'; + + const version = '1.0.0'; + log('-=> Image Editor v' + version + ' is loaded. Use !imageeditor to create interface'); + // 1.0.0 Debut + + + // ================================================== + // HELP HANDOUT + // ================================================== + const HELP_NAME = 'Help: Image Editor'; + const HELP_AVATAR = 'https://files.d20.io/images/470559564/QxDbBYEhr6jLMSpm0x42lg/original.png?1767857147'; + const HELP_TEXT = ` +

    Image Editor Guide

    +

    +The Image Editor allows you to select any handout that contains images and modify the style, layout, and properties of those images without manually editing HTML. Changes are written directly back to the handout's notes. +

    + +

    Getting Started

    +

    Type !imageeditor in chat to open the Image Editor interface. The editor opens as a handout called Image Editor in your journal. Only handouts that contain at least one image will appear in the chooser.

    + +

    Choosing a Handout

    +

    Click the Choose Handout button in the top right of the editor. A dropdown will appear listing all handouts that contain images, in alphabetical order. Selecting one will load its images into the editor.

    +

    The name of the currently selected handout appears as a link in the header. Below it, if the currently selected image is preceded by a header in the handout, that header will appear as a secondary link which jumps directly to that section of the handout.

    + +

    Thumbnails Panel

    +

    The left panel shows thumbnails of every image found in the selected handout. Click any thumbnail to load that image into the preview panel and populate the properties panel with its current values. The title of the image, if set, appears below each thumbnail.

    + +

    Preview Panel

    +

    The center panel shows a large preview of the currently selected image. At the top of the preview panel is a navigation bar with left and right arrow buttons for moving to the previous or next image without scrolling the thumbnail list. The small thumbnails beside the arrows show the adjacent images and are also clickable.

    + +

    Properties Panel

    +

    The right panel shows the editable properties of the currently selected image. Click any value to open a prompt where you can enter a new value. Leaving the prompt blank will remove that property from the image entirely.

    + +

    title

    +

    Sets the title attribute of the image tag. This text appears as a tooltip when the user hovers over the image.

    + +

    url

    +

    Sets the src attribute, replacing the image with a different one at the given URL.

    + +

    layout

    +

    Applies a float-based layout preset to the image. Options are:

    +
      +
    • left — floats the image to the left, text wraps around the right.
    • +
    • right — floats the image to the right, text wraps around the left.
    • +
    • center — displays the image as a centered block.
    • +
    • none — removes float and display properties.
    • +
    + +

    width / height

    +

    Sets the width or height of the image. Values must be in pixels (e.g. 200px) or percent (e.g. 50%).

    + +

    margin

    +

    Sets the margin around the image. Accepts 1 to 4 values in pixels or percent, space-separated, following standard CSS margin shorthand (e.g. 8px 16px).

    + +

    border-radius

    +

    Rounds the corners of the image. Value must be in pixels or percent (e.g. 8px).

    + +

    Presets

    +

    Below the properties are quick-apply preset buttons. Clicking a preset merges a predefined set of style values into the image's existing styles, preserving properties like border-radius that are not part of the preset.

    +
      +
    • Left 30% / 50% / 60% — floats the image left at the given width with a small right margin.
    • +
    • Right 30% / 50% / 60% — floats the image right at the given width with a small left margin.
    • +
    • Center — displays the image as a full-width centered block.
    • +
    • Clear — removes all inline style information from the image tag, returning it to its unstyled default.
    • +
    + +

    Commands

    +
      +
    • !imageeditor — Opens or refreshes the Image Editor interface.
    • +
    • !imageeditor --help — Creates or updates this help handout and whispers you a link to it.
    • +
    + +

    Notes

    +
      +
    • The Image Editor cannot be used to edit its own handout.
    • +
    • Only images in the notes field of a handout are visible to the editor. Images in gmnotes are not shown.
    • +
    • Style changes are written directly to the handout HTML. Always keep a backup of important handout content.
    • +
    +`; + + // ================================================== + // STATE + // ================================================== + const checkInstall = () => { + state.ImageEditor = state.ImageEditor || { + handoutId: null, + selectedIndex: 0, + // cachedHandoutOptions: array of "Name,id" strings for the picker. + // Rebuilt only on --choose and --refresh, not on every render. + cachedHandoutOptions: null + }; + // Remove legacy fields that should not be in persistent state. + if (state.ImageEditor.hasOwnProperty('currentImages')) { + delete state.ImageEditor.currentImages; + } + if (state.ImageEditor.hasOwnProperty('editorHandoutId')) { + delete state.ImageEditor.editorHandoutId; + } + }; + + // ================================================== + // CONFIG + // ================================================== + const Config = { + editorName: 'Image Editor', + + properties: { + width: { type: 'size' }, + 'max-width': { type: 'size' }, + height: { type: 'size' }, + 'max-height': { type: 'size' }, + 'border-radius': { type: 'size' }, + + layout: { + type: 'enum', + values: ['left', 'right', 'center', 'none'] + }, + + margin: { type: 'margin' }, + + title: { type: 'string', attribute: true }, + url: { type: 'string', attribute: true } + }, + + presets: [ + { label: 'Left 30%', styles: { width: '30%', float: 'left', display: 'inline', margin: '0 8px 8px 0' } }, + { label: 'Right 30%', styles: { width: '30%', float: 'right', display: 'inline', margin: '0 0 8px 8px' } }, + { label: 'Left 50%', styles: { width: '50%', float: 'left', display: 'inline', margin: '0 8px 8px 0' } }, + { label: 'Right 50%', styles: { width: '50%', float: 'right', display: 'inline', margin: '0 0 8px 8px' } }, + { label: 'Left 60%', styles: { width: '60%', float: 'left', display: 'inline', margin: '0 8px 8px 0' } }, + { label: 'Right 60%', styles: { width: '60%', float: 'right', display: 'inline', margin: '0 0 8px 8px' } }, + { label: 'Center', styles: { width: '100%', float: 'none', display: 'block', margin: '0 auto' } }, + { label: 'Clear', styles: null } + ] + }; + + // ================================================== + // CSS (CENTRALIZED) + // ================================================== + const CSS = { + header: 'font-weight:bold; font-size:18px; padding:8px; color:#ddd;', + headerRow: 'width:100%; background:#000; border:none; table-layout:fixed;', + headerCell: 'vertical-align:middle; border:none; background:#000; padding:0 8px;', + right: 'text-align:right;', + center: 'text-align:center; color:#ddd; font-weight:bold; font-size:18px;', + + table: 'width:100%; background:#000; color:#ccc; border:none; table-layout:fixed;', + + td: 'vertical-align:top; padding:10px;', + tdThumbnail: 'width:205px; vertical-align:top; padding:10px; text-align:center;', + tdPreview: 'vertical-align:top; padding:10px;', + tdControl: 'width:205px; vertical-align:top; padding:10px;', + + thumb: 'display:block; margin:8px auto 2px auto; width:125px; border:1px solid #444; border-radius:8px; background:transparent;', + preview: 'max-width:100%; max-height:300px; border:none; background:#111; padding:4px;', + + handoutButton: 'text-decoration:none; color:#6ca0ff; background:#222; border:1px solid #444; padding:4px 8px; border-radius:3px; cursor:pointer; font-size:14px;', + button: 'text-decoration:none; color:#6ca0ff; cursor:pointer;', + + label: 'font-weight:bold; color:#aaa;', + value: 'color:#6ca0ff;', + muted: 'color:#666;', + + panelHeader: 'font-weight:bold; color:#ddd; margin-bottom:8px; border-bottom:1px solid #444; padding-bottom:4px;', + + thumbnailPanel:'width:200px; max-height:500px; overflow-y:auto; background:#000; border:none; padding:8px; text-align:center;', + previewPanel: 'background:#000; border:none; padding:8px; text-align:center;', + controlPanel: 'width:200px; max-height:500px; overflow-y:auto; background:#000; border:none; padding:8px;', + previewHeader: 'width:100%; overflow:hidden; border-bottom:1px solid #444; padding-bottom:4px; margin-bottom:8px;', + navButton: 'text-decoration:none; color:#ddd; font-weight:bold; font-size:16px; padding:0 6px; background:#222; border:1px solid #444; border-radius:3px;', + navThumb: 'max-height:30px; border:1px solid #444; border-radius:3px; vertical-align:middle; margin:0 2px;', + + handoutLink: 'text-decoration:none; color:#fff; font-weight:bold; font-size:14px;', + headerLink: 'text-decoration:none; color:#aad4f5; font-weight:normal; font-size:11px; display:block; margin-top:3px;', + + // Chat launch message (sent when bare !imageeditor is typed) + launchBox: 'background:#111; border:1px solid #444; border-radius:4px; padding:10px 14px; font-family:sans-serif;', + launchTitle: 'font-size:15px; font-weight:bold; color:#ddd; margin-bottom:6px;', + launchLink: 'text-decoration:none; color:#6ca0ff; background:#222; border:1px solid #444; padding:5px 10px; border-radius:3px; font-size:13px; font-weight:bold;', + + // Help whisper box — same family as launchBox + helpLink: 'color:#9fd3ff; font-weight:bold; text-decoration:none;' + }; + + // ================================================== + // UTILITIES + // ================================================== + const Utils = { + + send: (msg) => sendChat('ImageEditor', msg), + whisper: (who, msg) => sendChat('ImageEditor', `/w "${who.replace(/ \(GM\)$/,'')}" ${msg}`), + + buildEnumQuery: (title, values, current) => { + const opts = values.map(v => `${v}${v === current ? '*' : ''}`); + return `?{${title}|${opts.join('|')}}`; + }, + + buildSizeQuery: (title, current) => `?{${title} (px or %)|${current || ''}}`, + buildMarginQuery: (current) => `?{Margin (1-4 values px or %)|${current || ''}}`, + + // ------------------------------------------------------------------ + // rebuildHandoutCache + // ------------------------------------------------------------------ + // Asynchronously scans all handouts for images and stores the result + // in state.ImageEditor.cachedHandoutOptions as an array of "Name,id" + // strings. Calls callback() when done (no arguments). + // + // Call this ONLY on --choose and --refresh. All other operations + // must use the cache directly — never call this on every render. + // ------------------------------------------------------------------ + rebuildHandoutCache: (callback) => { + const handouts = findObjs({ type: 'handout' }) + .sort((a, b) => a.get('name').localeCompare(b.get('name'))); + + if (!handouts.length) { + state.ImageEditor.cachedHandoutOptions = []; + callback(); + return; + } + + const results = []; + let remaining = handouts.length; + + handouts.forEach(h => { + h.get('notes', text => { + const hasImage = /]*>/i.test(text || ''); + if (hasImage) results.push(h); + remaining--; + if (remaining === 0) { + state.ImageEditor.cachedHandoutOptions = + results.map(r => `${r.get('name')},${r.id}`); + callback(); + } + }); + }); + }, + + // Return the cached picker options, or an empty array if not yet built. + getCachedOptions: () => state.ImageEditor.cachedHandoutOptions || [], + + // Always look up the editor handout by name. This means a rename or + // deletion is handled gracefully — the script never holds a stale ID. + // findObjs is an in-memory index lookup and is fast enough to call + // on every command without caching. + getEditorHandout: () => { + const existing = findObjs({ type: 'handout', name: Config.editorName })[0]; + if (existing) return existing; + + // Not found — create it fresh. + log(`[ImageEditor] "${Config.editorName}" handout not found — creating.`); + return createObj('handout', { + name: Config.editorName, + inplayerjournals: 'all' + }); + }, + + // ------------------------------------------------------------------ + // findPrecedingHeader + // ------------------------------------------------------------------ + // Scans the handout HTML for the nearest h1-h4 that appears before + // the Nth tag (0-based). Returns { text, level } or null. + // ------------------------------------------------------------------ + findPrecedingHeader: (html, imgIndex) => { + + // Step 1: find the character offset of the target tag. + const imgRe = /]*>/gi; + let count = 0; + let imgPos = -1; + let m; + + while ((m = imgRe.exec(html)) !== null) { + if (count === imgIndex) { imgPos = m.index; break; } + count++; + } + + if (imgPos === -1) return null; + + // Step 2: find the last h1-h4 whose opening tag starts before imgPos. + // Work on only the substring before the image for efficiency. + const before = html.slice(0, imgPos); + const headRe = /]*>([\s\S]*?)<\/h\1>/gi; + let lastHeader = null; + let hm; + + while ((hm = headRe.exec(before)) !== null) { + const text = hm[2].replace(/<[^>]+>/g, '').trim(); + if (text) lastHeader = { text, level: parseInt(hm[1], 10) }; + } + + return lastHeader; + } + }; + + // ================================================== + // COMMAND PARSER + // ================================================== + const parseArgs = (content) => { + const tokens = content.split(/\s+--/).slice(1); + const args = {}; + + tokens.forEach(t => { + const [key, ...rest] = t.split(/\s+/); + const body = rest.join(' '); + + if (key === 'set') { + const [prop, ...valParts] = body.split('|'); + const value = valParts.join('|').trim(); + args.set = args.set || []; + args.set.push({ property: prop.trim(), value }); + } else { + args[key] = body.trim(); + } + }); + + return args; + }; + + // ================================================== + // STYLE ENGINE + // ================================================== + const StyleEngine = { + + parse: (str) => { + const o = {}; + if (!str) return o; + str.split(';').forEach(p => { + const idx = p.indexOf(':'); + if (idx === -1) return; + const k = p.slice(0, idx).trim(); + const v = p.slice(idx + 1).trim(); + if (k && v) o[k] = v; + }); + return o; + }, + + serialize: (o) => + Object.entries(o).map(([k, v]) => `${k}:${v}`).join('; '), + + validateSize: (v) => + /^\d+(px|%)$/.test(v) ? v : null, + + validateMargin: (v) => { + v = v.trim().replace(/\s+/g, ' '); + return /^(\d+(px|%))( \d+(px|%)){0,3}$/.test(v) ? v : null; + }, + + applyLayout: (style, layout) => { + delete style.float; + delete style.display; + + if (layout === 'left') { style.float = 'left'; style.display = 'inline'; style.margin = '0'; } + if (layout === 'right') { style.float = 'right'; style.display = 'inline'; style.margin = '0'; } + + if (layout === 'center') { + style.float = 'none'; + style.display = 'block'; + if (style.margin) { + const p = style.margin.split(' '); + if (p.length === 1) style.margin = `${p[0]} auto`; + if (p.length === 2) style.margin = `${p[0]} auto`; + if (p.length === 3) style.margin = `${p[0]} auto ${p[2]}`; + if (p.length === 4) style.margin = `${p[0]} auto ${p[2]} auto`; + } else { + style.margin = '0 auto'; + } + } + return style; + } + }; + + // ================================================== + // IMAGE PARSER + // ================================================== + const Parser = { + + extractImages: (html) => { + const matches = html.match(/]*>/gi) || []; + return matches.map(tag => { + const get = (a) => { + const m = tag.match(new RegExp(`${a}="([^"]*)"`, 'i')); + return m ? m[1] : ''; + }; + return { tag, src: get('src'), style: get('style'), title: get('title') }; + }); + }, + + // Replace the first occurrence of oldTag in html using a literal + // string search (indexOf + slice) rather than .replace(), which would + // misinterpret regex special characters in image src URLs. + replaceImage: (html, oldTag, newTag) => { + const pos = html.indexOf(oldTag); + if (pos === -1) return html; + return html.slice(0, pos) + newTag + html.slice(pos + oldTag.length); + }, + + rebuildTag: (img, styleStr) => { + let t = img.tag + .replace(/\sstyle="[^"]*"/i, '') + .replace(/\stitle="[^"]*"/i, '') + .replace(/\ssrc="[^"]*"/i, ''); + return t.replace(' { + + const handoutId = state.ImageEditor.handoutId; + + let centreContent; + if (handoutName) { + const handoutUrl = `http://journal.roll20.net/handout/${handoutId}`; + const handoutLink = `${handoutName}`; + const deepLink = headerInfo + ? `${headerInfo.text}` + : ''; + centreContent = handoutLink + deepLink; + } else { + centreContent = `No Handout Selected`; + } + + const pickerQuery = (handoutOptions && handoutOptions.length) + ? `?{Select Handout|${handoutOptions.join('|')}}` + : '?{No handouts with images found|}'; + + return ` + + + + + + +
    +
    Image Editor
    +
    + ${centreContent} + + + Choose Handout + + + ? + +
    `; + }, + + thumbs: (images) => + images.map((i, idx) => ` + + + +
    ${i.title || ''}
    + `).join(''), + + controls: (img) => { + + const style = StyleEngine.parse(img.style); + + const control = (name, val, query) => + `
    + ${name}: + + ${val || '—'} + +
    `; + + const propOrder = ['title', 'url', 'layout', 'width', 'height', 'margin', 'border-radius']; + + const propertyControls = propOrder.map(p => { + const conf = Config.properties[p]; + if (!conf) return ''; + + let val = (p === 'title') ? img.title : style[p]; + val = val || '—'; + + let query = ''; + if (conf.type === 'enum') query = Utils.buildEnumQuery(p, conf.values, val === '—' ? '' : val); + if (conf.type === 'size') query = Utils.buildSizeQuery(p, val === '—' ? '' : val); + if (conf.type === 'margin') query = Utils.buildMarginQuery(val === '—' ? '' : val); + if (conf.type === 'string') query = `?{${p}|${val === '—' ? '' : val}}`; + + return control(p, val, query); + }).join(''); + + const presetButtons = Config.presets.map(p => + ` + ${p.label} + ` + ).join(' '); + + return propertyControls + ` +
    +
    Presets
    + ${presetButtons} +
    `; + }, + + render: (handout, images, handoutOptions, headerInfo) => { + + // Clamp idx to valid range — state may hold a stale index from a + // previous session with more images than the current array has. + const idx = images.length > 0 + ? Math.max(0, Math.min(state.ImageEditor.selectedIndex || 0, images.length - 1)) + : 0; + const img = images[idx] || null; + + return ` +
    + ${Renderer.header(handout.get('name'), handoutOptions, headerInfo)} + + + + + + +
    +
    +
    Thumbnails
    + ${Renderer.thumbs(images)} +
    +
    +
    +
    + + ${img && idx > 0 + ? `` + : ``} + + + ${img && idx < images.length - 1 + ? `` + : ``} + + Image Preview +
    + ${img + ? `` + : `
    No image selected
    `} +
    +
    +
    +
    Properties
    + ${img ? Renderer.controls(img) : '
    No image selected
    '} +
    +
    +
    `; + } + }; + + // ================================================== + // RENDER HELPERS + // ================================================== + // Central render call used by all command paths. + // Uses the cached handout options — never rebuilds the list. + const renderEditor = (handout, notes, images) => { + const editor = Utils.getEditorHandout(); + const options = Utils.getCachedOptions(); + const idx = state.ImageEditor.selectedIndex || 0; + const headerInfo = Utils.findPrecedingHeader(notes, idx); + editor.set('notes', Renderer.render(handout, images, options, headerInfo)); + }; + + // Render the empty-state panel (no handout chosen yet) into the editor + // handout. Called on script load and whenever the editor needs to show + // its initial UI so the GM has something to interact with. + const renderEmptyPanel = () => { + const editor = Utils.getEditorHandout(); + editor.set('notes', Renderer.render({ get: () => null }, [], Utils.getCachedOptions(), null)); + }; + + // ================================================== + // MAIN + // ================================================== + const handleInput = (msg) => { + + if (msg.type !== 'api') return; + if (!msg.content.startsWith('!imageeditor')) return; + + // ------------------------------------------------------------------ + // Bare command: !imageeditor (no sub-command) + // Ensure the editor handout exists, then whisper a styled clickable + // link to it so the GM can open it without hunting through the journal. + // ------------------------------------------------------------------ + const trimmed = msg.content.trim(); + if (trimmed === '!imageeditor') { + const editor = Utils.getEditorHandout(); + const url = `http://journal.roll20.net/handout/${editor.id}`; + Utils.whisper(msg.who, + `
    ` + + `
    Image Editor
    ` + + `Open Image Editor` + + `
    ` + ); + // Also ensure the handout has content — renders the empty-state + // panel if the handout was just created or was previously blank. + editor.get('notes', notes => { + if (!notes || !notes.trim()) renderEmptyPanel(); + }); + return; + } + + // ------------------------------------------------------------------ + // --help: create or update the Help: Image Editor handout, whisper link. + // ------------------------------------------------------------------ + if (trimmed === '!imageeditor --help') { + let helpHandout = findObjs({ type: 'handout', name: HELP_NAME })[0]; + if (!helpHandout) { + helpHandout = createObj('handout', { + name: HELP_NAME, + archived: false, + avatar: HELP_AVATAR + }); + } + helpHandout.set('avatar', HELP_AVATAR); + helpHandout.set('notes', HELP_TEXT); + const helpUrl = `http://journal.roll20.net/handout/${helpHandout.id}`; + const helpBox = + `
    ` + + `
    Image Editor Help
    ` + + `Open Help Handout` + + `
    `; + sendChat('ImageEditor', `/w gm ${helpBox}`, null, { noarchive: true }); + return; + } + + const args = parseArgs(msg.content); + const editor = Utils.getEditorHandout(); + + // ------------------------------------------------------------------ + // --choose: set new target handout, rebuild the picker cache, render. + // This is the ONLY path that calls rebuildHandoutCache. + // ------------------------------------------------------------------ + if (args.choose) { + state.ImageEditor.handoutId = args.choose; + state.ImageEditor.selectedIndex = 0; + + const handout = getObj('handout', args.choose); + if (!handout) { + editor.set('notes', Renderer.render({ get: () => null }, [], Utils.getCachedOptions(), null)); + return; + } + + Utils.rebuildHandoutCache(() => { + handout.get('notes', notes => { + const images = Parser.extractImages(notes); + if (!images.length) { + editor.set('notes', + Renderer.header(handout.get('name'), Utils.getCachedOptions(), null) + + '

    No images found in this handout.

    ' + ); + return; + } + renderEditor(handout, notes, images); + }); + }); + return; + } + + // ------------------------------------------------------------------ + // All other commands operate on the already-chosen handout. + // They use the cached picker list and never scan all handouts. + // ------------------------------------------------------------------ + const handout = getObj('handout', state.ImageEditor.handoutId); + + if (!handout) { + editor.set('notes', Renderer.render({ get: () => null }, [], Utils.getCachedOptions(), null)); + return; + } + + if (handout.get('name') === Config.editorName) { + Utils.whisper(msg.who, 'Cannot edit images in the Image Editor handout.'); + return; + } + + handout.get('notes', notes => { + + let images = Parser.extractImages(notes); + + if (!images.length) { + editor.set('notes', + Renderer.header(handout.get('name'), Utils.getCachedOptions(), null) + + '

    No images found in this handout.

    ' + ); + return; + } + + // --select + if (args.select !== undefined) { + state.ImageEditor.selectedIndex = Math.max(0, + Math.min(images.length - 1, parseInt(args.select))); + } + + let img = images[state.ImageEditor.selectedIndex]; + let style = StyleEngine.parse(img.style); + + // --set + if (args.set) { + args.set.forEach(s => { + const conf = Config.properties[s.property]; + if (!conf) return; + const value = s.value.trim(); + + if (!value) { + if (conf.type === 'string') { img.title = ''; } + else { delete style[s.property]; } + if (s.property === 'layout') style = StyleEngine.applyLayout(style, 'none'); + return; + } + if (conf.type === 'size') { const v = StyleEngine.validateSize(value); if (v) style[s.property] = v; } + if (conf.type === 'margin') { const v = StyleEngine.validateMargin(value); if (v) style.margin = v; } + if (conf.type === 'enum') { + if (conf.values.includes(value) && s.property === 'layout') { + style = StyleEngine.applyLayout(style, value); + style.layout = value; + } + } + if (conf.type === 'string') { + if (s.property === 'url') img.src = value; + else img.title = value; + } + }); + + const newTag = Parser.rebuildTag(img, StyleEngine.serialize(style)); + notes = Parser.replaceImage(notes, img.tag, newTag); + handout.set('notes', notes); + images = Parser.extractImages(notes); + } + + // Re-fetch img after possible --set update + img = images[state.ImageEditor.selectedIndex]; + style = StyleEngine.parse(img.style); + + // --preset + if (args.preset) { + try { + const presetStyles = JSON.parse(decodeURIComponent(args.preset)); + if (presetStyles === null) { + style = {}; + } else { + Object.assign(style, presetStyles); + } + } catch(e) { + log('ImageEditor: failed to parse preset — ' + e); + } + const newTag = Parser.rebuildTag(img, StyleEngine.serialize(style)); + notes = Parser.replaceImage(notes, img.tag, newTag); + handout.set('notes', notes); + images = Parser.extractImages(notes); + } + + renderEditor(handout, notes, images); + }); + }; + + // ================================================== + checkInstall(); + on('chat:message', handleInput); + + // Prime the cache on script load, then render the empty-state panel so + // the handout has content immediately — even if it was just created. + Utils.rebuildHandoutCache(() => { + log('-=> Image Editor: handout cache primed (' + + (state.ImageEditor.cachedHandoutOptions || []).length + ' handouts with images).'); + // Only write the empty panel if the handout is genuinely blank — + // don't overwrite a valid session that survived a sandbox restart. + const editor = Utils.getEditorHandout(); + editor.get('notes', notes => { + if (!notes || !notes.trim()) renderEmptyPanel(); + }); + }); + +}); + +})(); + diff --git a/ImageEditor/readme.md b/ImageEditor/readme.md new file mode 100644 index 000000000..2d009a01f --- /dev/null +++ b/ImageEditor/readme.md @@ -0,0 +1,111 @@ +# Image Editor + +## Overview + +The **Image Editor** provides a graphical interface inside Roll20 for editing images embedded in handouts. It allows you to modify layout, styling, and attributes without directly editing HTML. + +All changes are written back to the edited handout's notes field automatically in real time. + +--- + +## Features + +- Visual interface rendered as a handout +- Thumbnail browser for all images in a handout +- Live preview with navigation controls +- Editable image properties: + - title (tooltip) + - url (image source) + - layout (left, right, center, none) + - width / height + - margin (CSS shorthand) + - border-radius +- Preset styling buttons for quick formatting +- Automatic detection of handouts containing images +- Context-aware navigation to image sections + +--- + +## Usage + +### Open the Editor + +Type the following in chat: + +`!imageeditor` + +This opens (or refreshes) the **Image Editor** handout. + +--- + +### Select a Handout + +- Click **Choose Handout** in the editor +- Select from a list of handouts that contain images +- Images will load into the interface + +--- + +### Interface Layout + +**Thumbnails Panel (Left)** +Displays all images in the selected handout. + +**Preview Panel (Center)** +Shows the selected image with navigation controls. + +**Properties Panel (Right)** +Allows editing of image attributes and styles. + +--- + +### Editing Properties + +Click any property value to edit it. + +Leaving a value blank removes that property. + +#### Supported Properties + +- **title** — Tooltip text +- **url** — Image source URL +- **layout** — Float/display behavior +- **width / height** — Size (px or %) +- **margin** — CSS margin shorthand +- **border-radius** — Corner rounding + +--- + +### Presets + +Quick styling options: + +- Left / Right (30%, 40%, 50%, 60%) +- Center +- Clear (removes all inline styles) + +Presets merge with existing styles where applicable. + +--- + +## Commands + +`!imageeditor` + +Opens or refreshes the editor interface. + + +`!imageeditor --help` + +Creates or updates the help handout and whispers a link to it. + +--- + +## Notes + +- The editor cannot modify its own handout. +- Only images in the **notes** field are processed. +- Images in **gmnotes** are ignored. +- All edits directly modify handout HTML. + +**Always keep backups of important handouts.** \ No newline at end of file diff --git a/ImageEditor/script.json b/ImageEditor/script.json new file mode 100644 index 000000000..742a24622 --- /dev/null +++ b/ImageEditor/script.json @@ -0,0 +1,14 @@ +{ + "name": "Image Editor", + "script": "ImageEditor.js", + "version": "1.0.0", + "description": "Provides a graphical interface for editing images inside Roll20 handouts, including layout, styling, and attributes without manual HTML editing.", + "authors": "Keith Curtis", + "roll20userid": "162065", + "modifies": { + "handout": "read,write" + }, + "dependencies": [], + "conflicts": [], + "previousversions": ["1.0.0"] +} \ No newline at end of file diff --git a/PinNote/1.0.0/PinNote.js b/PinNote/1.0.0/PinNote.js new file mode 100644 index 000000000..1f12c2e94 --- /dev/null +++ b/PinNote/1.0.0/PinNote.js @@ -0,0 +1,353 @@ +// Script: PinNote +// By: Keith Curtis +// Contact: https://app.roll20.net/users/162065/keithcurtis + +const pinnote = (() => { +//(() => { + 'use strict'; + + const version = '1.0.0'; //version number set here + log('-=> PinNote v' + version + ' is loaded.'); + //Changelog + //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)); + + }); + +})(); \ No newline at end of file diff --git a/PinNote/PinNote.js b/PinNote/PinNote.js new file mode 100644 index 000000000..1f12c2e94 --- /dev/null +++ b/PinNote/PinNote.js @@ -0,0 +1,353 @@ +// Script: PinNote +// By: Keith Curtis +// Contact: https://app.roll20.net/users/162065/keithcurtis + +const pinnote = (() => { +//(() => { + 'use strict'; + + const version = '1.0.0'; //version number set here + log('-=> PinNote v' + version + ' is loaded.'); + //Changelog + //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)); + + }); + +})(); \ No newline at end of file diff --git a/PinNote/readme.md b/PinNote/readme.md new file mode 100644 index 000000000..066875d9c --- /dev/null +++ b/PinNote/readme.md @@ -0,0 +1,83 @@ +# PinNote Script + +The **PinNote** script sends information from linked or custom map pins to chat using any Supernotes template. +You must have Supernotes installed. + +--- + +## Arguments + +Arguments are case-insensitive and use the format: + +``` +--key|value +``` + +--- + +### `--to|` + +Controls where the message is sent. + +#### `--to|pc` + +Sends a public message to chat. + +- GM notes are **never included**. + +--- + +#### `--to|gm` + +Whispers the message to the GM only. + +- GM notes **are included**. + +--- + +#### `--to|self` + +Whispers the message to the invoking player. + +- GM notes are included **only if the invoker is a GM**. + +--- + +If a non-GM runs the command, `--to` is ignored and treated as `pc`. + +--- + +### `--template|` (optional) + +Selects a Supernotes display template. + +- If omitted or invalid, the **generic** template is used silently. + +--- + +## Examples + +``` +!pinnote +!pinnote --to|gm +!pinnote --to|self --template|dark +!pinnote --template|wizard +``` + +--- + +## Requirements + +- Exactly **one map pin** must be selected. + - If none are selected, the script reports an error. + - If multiple are selected, only the first pin is used. + +- The pin may be: + - A **linked pin** (pulls data from its linked handout), or + - A **custom pin** (uses its Notes field). + +- A custom pin must contain notes. + - If the Notes field is empty, nothing is sent and an error is shown. + +- **Supernotes must be installed.** + - If missing, the script exits and notifies the GM. \ No newline at end of file diff --git a/PinNote/script.json b/PinNote/script.json new file mode 100644 index 000000000..04e5cccc5 --- /dev/null +++ b/PinNote/script.json @@ -0,0 +1,15 @@ +{ + "name": "PinNote", + "script": "PinNote.js", + "version": "1.0.0", + "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", + "dependencies": ["Supernotes"], + "modifies": { + "pin": "read", + "handout": "read" + }, + "conflicts": [], + "previousversions": ["1.0.0"] +} \ No newline at end of file diff --git a/PinTool/1.0.5/PinTool.js b/PinTool/1.0.5/PinTool.js new file mode 100644 index 000000000..2880ee014 --- /dev/null +++ b/PinTool/1.0.5/PinTool.js @@ -0,0 +1,2908 @@ +// Script: PinTool +// By: Keith Curtis +// Contact: https://app.roll20.net/users/162065/keithcurtis + +const pintool ="1.0.5"; + +on("ready", () => +{ + + const version = '1.0.5'; //version number set here + log('-=> PinTool v' + version + ' is loaded. Use !pintool --help for documentation.'); + //1.0.5 Created Named Space for the script + //1.0.4 Huge update: 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 + + + // ============================================================ + // 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 helpButton = `
    ` + messageButton("?", "!pintool --help") +`
    `; + 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 +or transfers image data between pins and graphics. +

    + +

    Formats:

    +
    +!pintool --transform autotext [filter|target]
    +!pintool --transform imageto|pin
    +!pintool --transform imageto|graphic
    +
    + +

    Supported Transforms

    + +
      +
    • + autotext
      + Derives up to 3 characters from the pin’s title (or subLink if the title is empty) + and converts the pin into a text icon. +
    • + +
    • + imageto|pin
      + Copies the image from a selected graphic to a selected pin, + setting the pin to use customizationType|image. +
    • + +
    • + imageto|graphic
      + Copies the stored image from a selected pin to a selected graphic. +
    • +
    + +

    Autotext Behavior

    + +

    +Text is derived from the first alphanumeric characters found in the +pin’s title. If the title is empty, subLink is used instead. +If no valid characters are found, the pin is not modified. +

    + +

    +The filter| argument controls which pins are processed +and follows the same targeting rules as the --set command. +

    + +

    Image Transfer Behavior

    + +
      +
    • if transferring graphic image to pin Exactly one graphic and any number of pins must be selected.
    • +
    • if transferring pin image to graphic Exactly one pin and any number of graphic must be selected.
    • +
    • The direction of transfer is determined by the command argument.
    • +
    • No filter options apply to image transfers.
    • +
    • If the required objects are not selected, the command aborts with an error.
    • +
    + +
    + +

    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 --transform imageto|pin
      Copies the image from a selected graphic to a selected pin
    • +
    • !pintool --transform imageto|graphic
      Copies the image stored on a pin to a selected graphic
    • +
    • !pintool --library
      Browse saved pin styles
    • +
    • !pintool --set x|?{Input scale transformation using +-/* number} y|?{Input scale transformation using +-/* number}
      Use when you have scaled a page and wish all of the pins to scale porportionately, preserving their spatial relationships
    • + + + + +
    + +
    + +

    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}`, null, {noarchive: true}); + } + + +const hSpace = `
    `; + + function getCSS() + { + return { + messageContainer: "background:#1e1e1e;" + + "border:1px solid #888;" + + "border-radius:6px;" + + "padding:6px;" + + "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:6px 4px 6px 6px;" + + "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:2px;" + + "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;" + + "margin-left:6px;" + }; + } + + 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("S", "!pintool --set scale|small") + + splitButton("M", "!pintool --set scale|medium") + + splitButton("L", "!pintool --set scale|large") + + splitButton("Huge", "!pintool --set scale|huge") + + splitButton("Gig", "!pintool --set scale|gigantic") + + `
    ` + + + // VISIBILITY + `
    Visible
    ` + + splitButton("GM", "!pintool --set visibleTo|") + + splitButton("All", "!pintool --set visibleTo|all") + + hSpace + + splitButton("Show Name", "!pintool --set nameplateVisibleTo|all") + + splitButton("Hide Name", "!pintool --set nameplateVisibleTo|") + + `
    ` + + + // BLOCKQUOTE + `
    Blockquote as player text
    ` + + splitButton("On", "!pintool --set autoNotesType|blockquote") + + splitButton("Off", "!pintool --set autoNotesType|") + + `
    ` + + + + // TOOLTIP IMAGE + `
    Tooltip Image
    ` + + splitButton("Custom", "!pintool --set tooltipImage|?{Roll20 Image URL}") + + splitButton("On", "!pintool --set imageVisibleTo|all") + + splitButton("Off", "!pintool --set imageVisibleTo|") + + hSpace + + splitButton("S", "!pintool --set tooltipImageSize|small") + + splitButton("M", "!pintool --set tooltipImageSize|medium") + + splitButton("L", "!pintool --set tooltipImageSize|large") + + splitButton("XL", "!pintool --set tooltipImageSize|xl") + + `
    ` + + + // 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") + + + + `
    ` + + + + // 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") + + `
    ` + + */ + + // SHAPE + `
    Appearance: Shape, Color, Image
    ` + + 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|transparent") + + splitButton("Custom Color", "!pintool --set bgColor|?{Enter custom color (hex or transparent)}") + + `
    ` + + + `Pin Image: ` + + splitButton("Set", "!pintool --set pinImage|?{Roll20 Image URL}") + + splitButton("Clear", "!pintool --set pinImage| customizationType|icon") + + splitButton("From Graphic", "!pintool --transform imageto|pin") + + + + // Pin LIbrary + `
    Utilities ` + `
    ` + + messageButton("Style Library", "!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 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" + helpButton, + panel + ); + } + + +function deriveAutoText(str) +{ + if(!str) return ""; + + // Keep alphanumeric only + const cleaned = (str.match(/[A-Za-z0-9]/g) || []).join(""); + + return cleaned.substring(0, 3); +} + + + + 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()); + + sendStyledMessage(`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 + { +sendStyledMessage( + `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 mid = Math.ceil(keywords.length / 2); + const leftColumn = keywords.slice(0, mid); + const rightColumn = keywords.slice(mid); + + let rows = ""; + + for(let i = 0; i < mid; i++) + { + const left = leftColumn[i] + ? `${leftColumn[i]}` + : ""; + + const right = rightColumn[i] + ? `${rightColumn[i]}` + : ""; + + rows += `
    ${left}${right}
    ${rows}
    `; + + const output = + `
    +
    Pin Library${helpButton}
    +
    + ${buttons} +
    +
    +${messageButton("Main Menu", "!pintool")} +
    +
    `.trim().replace(/\r?\n/g, ''); + + sendChat("PinTool", `/w gm ${output}`, null, {noarchive: true}); +} + + +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 = "#eee"; + 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}${helpButton}
    +
    + ${buttons} +
    +
    +${splitButton("Change Keyword", "!pintool --library")} + ${splitButton("Main Menu", "!pintool")} +
    +
    `.trim().replace(/\r?\n/g, ''); + + sendChat("PinTool", `/w gm ${output}`, null, {noarchive: true}); + } + + + 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) + { + sendStyledMessage("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", + "iconText", + "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 = []; + +// Default / selected +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); +} + +// Explicit all (UNCHANGED) +else if(filterRaw === "all") +{ + pins = findObjs({ + _type: "pin", + _pageid: pageId + }); +} + +// Property-based filter (NEW) +else if(PIN_SET_PROPERTIES.hasOwnProperty(filterRaw)) +{ + if(!msg.selected?.length) + return sendError("Select a reference pin for property-based filtering."); + + const reference = getObj("pin", msg.selected[0]._id); + if(!reference || reference.get("_pageid") !== pageId) + return sendError("Reference pin must be on the current page."); + + const referenceValue = reference.get(filterRaw); + + pins = findObjs({ + _type: "pin", + _pageid: pageId + }).filter(p => p.get(filterRaw) === referenceValue); +} + +// Explicit IDs (UNCHANGED) +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 +// Color normalization + validation +else if(spec === "color") +{ + value = value.trim(); + + // Allow transparent unchanged + if(value.toLowerCase() === "transparent") + { + value = "transparent"; + } + else + { + // If no leading # but looks like 6 or 8 hex digits, add it + if(!value.startsWith("#") && /^[0-9a-fA-F]{6}([0-9a-fA-F]{2})?$/.test(value)) + { + value = "#" + value; + } + + // Now validate final format strictly + if(!/^#[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 === "") + { + // Explicitly set blank text icon + value = ""; + } + else + { + // Preserve exactly what the user entered + // Only enforce 3-character limit + 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(pin) +{ + if(!pin) return ""; + + const title = (pin.get("title") || "").trim(); + const subLink = (pin.get("subLink") || "").trim(); + + const source = title || subLink; + if(!source) return ""; + + // Remove leading non-alphanumeric characters + const trimmed = source.replace(/^[^A-Za-z0-9]+/, ""); + + // Capture first contiguous alphanumeric run + const match = trimmed.match(/^[A-Za-z0-9]+/); + + if(!match) return ""; + + return match[0].substring(0, 3); +} + + +function handleTransform(msg, argString) +{ + if(!argString) + return sendError("No transform specified."); + + const tokens = argString.split(/\s+/); + const transformType = tokens[0].toLowerCase(); + +// ------------------------------------------------------------ +// Image Transfer (graphic <-> pin) +// ------------------------------------------------------------ + +if(transformType.startsWith("imageto|")) +{ + const direction = transformType.split("|")[1]; + + if(!msg.selected || !msg.selected.length) + { + return sendStyledMessage( + "Image Transfer", + "Usage: !pintool --transform imageto|pin OR imageto|graphic
    " + + "Select one source object and one or more targets." + ); + } + + let graphics = []; + let pins = []; + + msg.selected.forEach(s => + { + const obj = getObj(s._type, s._id); + if(!obj) return; + + if(s._type === "graphic") graphics.push(obj); + if(s._type === "pin") pins.push(obj); + }); + + // ------------------------------------------------ + // Graphic → Pins + // ------------------------------------------------ + if(direction === "pin") + { + if(graphics.length !== 1 || pins.length < 1) + { + return sendStyledMessage( + "Image Transfer", + "To transfer an image to pins:
    " + + "Select exactly one graphic and one or more pins." + ); + } + + const img = graphics[0].get("imgsrc"); + + if(!img) + { + return sendStyledMessage( + "Image Transfer", + "The selected graphic does not contain a usable image." + ); + } + + pins.forEach(p => + { + p.set({ + pinImage: img, + customizationType: "image" + }); + }); + + return; + } + + // ------------------------------------------------ + // Pin → Graphics + // ------------------------------------------------ + if(direction === "graphic") + { + if(pins.length !== 1 || graphics.length < 1) + { + return sendStyledMessage( + "Image Transfer", + "To transfer an image to graphics:
    " + + "Select exactly one pin and one or more graphics." + ); + } + + const img = pins[0].get("pinImage"); + + if(!img) + { + return sendStyledMessage( + "Image Transfer", + "The selected pin does not contain a stored image." + ); + } + + graphics.forEach(g => + { + g.set("imgsrc", img); + }); + + return; + } + + return sendStyledMessage( + "Image Transfer", + "Usage: !pintool --transform imageto|pin OR imageto|graphic" + ); +} + + // ------------------------------------------------------------ + // Existing Transform Logic + // ------------------------------------------------------------ + + 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 = []; + + // Default / selected + 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); + } + + // Explicit all (UNCHANGED) + else if(filterRaw === "all") + { + pins = findObjs({ + _type: "pin", + _pageid: pageId + }); + } + + // Property-based filter (NEW) + else if(PIN_SET_PROPERTIES.hasOwnProperty(filterRaw)) + { + if(!msg.selected?.length) + return sendError("Select a reference pin for property-based filtering."); + + const reference = getObj("pin", msg.selected[0]._id); + if(!reference || reference.get("_pageid") !== pageId) + return sendError("Reference pin must be on the current page."); + + const referenceValue = reference.get(filterRaw); + + pins = findObjs({ + _type: "pin", + _pageid: pageId + }).filter(p => p.get(filterRaw) === referenceValue); + } + + // Explicit IDs (UNCHANGED) + 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 derived = deriveAutoText(p); + + 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(); +} + + + + // ============================================================ + // ALIGN PINS + // ============================================================ + +function handleAlign(msg, args) +{ + if(!args.length) + return sendError("No alignment specified."); + + if(!msg.selected || msg.selected.length < 2) + return sendError("Select at least two pins."); + + const pins = msg.selected + .map(s => getObj("pin", s._id)) + .filter(Boolean); + + if(pins.length !== msg.selected.length) + return sendError("--align only operates on pins."); + + // -------- DEBUG: log positions before -------- + log("=== PinTool ALIGN BEFORE ==="); + pins.forEach(p => + log(`PIN ${p.id} X:${p.get("x")} Y:${p.get("y")}`) + ); + + const ops = args.map(a => a.toLowerCase()); + + const horizontal = ["left","right","hcenter","hdist"]; + const vertical = ["top","bottom","vcenter","vdist"]; + + const hOps = ops.filter(o => horizontal.includes(o)); + const vOps = ops.filter(o => vertical.includes(o)); + + if(hOps.length > 1) + return sendError("Conflicting horizontal alignments."); + + if(vOps.length > 1) + return sendError("Conflicting vertical alignments."); + + if(ops.includes("hdist") && pins.length < 3) + return sendError("hdist requires at least three pins."); + + if(ops.includes("vdist") && pins.length < 3) + return sendError("vdist requires at least three pins."); + + const queue = [...ops]; + + const process = () => + { + if(!queue.length) + { + // -------- DEBUG: log positions after -------- + log("=== PinTool ALIGN AFTER ==="); + pins.forEach(p => + log(`PIN ${p.id} X:${p.get("x")} Y:${p.get("y")}`) + ); + return; + } + + const op = queue.shift(); + + if(op === "left") + { + const x = Math.min(...pins.map(p=>p.get("x"))); + pins.forEach(p=>p.set("x",x)); + } + + else if(op === "right") + { + const x = Math.max(...pins.map(p=>p.get("x"))); + pins.forEach(p=>p.set("x",x)); + } + + else if(op === "top") + { + const y = Math.min(...pins.map(p=>p.get("y"))); + pins.forEach(p=>p.set("y",y)); + } + + else if(op === "bottom") + { + const y = Math.max(...pins.map(p=>p.get("y"))); + pins.forEach(p=>p.set("y",y)); + } + + else if(op === "hcenter") + { + const avg = Math.round( + pins.reduce((a,p)=>a+p.get("x"),0) / pins.length + ); + pins.forEach(p=>p.set("x",avg)); + } + + else if(op === "vcenter") + { + const avg = Math.round( + pins.reduce((a,p)=>a+p.get("y"),0) / pins.length + ); + pins.forEach(p=>p.set("y",avg)); + } + + else if(op === "hdist") + { + const sorted = [...pins].sort((a,b)=>a.get("x")-b.get("x")); + + const min = sorted[0].get("x"); + const max = sorted[sorted.length-1].get("x"); + + const step = (max-min)/(sorted.length-1); + + sorted.forEach((p,i)=> + p.set("x",Math.round(min + step*i)) + ); + } + + else if(op === "vdist") + { + const sorted = [...pins].sort((a,b)=>a.get("y")-b.get("y")); + + const min = sorted[0].get("y"); + const max = sorted[sorted.length-1].get("y"); + + const step = (max-min)/(sorted.length-1); + + sorted.forEach((p,i)=> + p.set("y",Math.round(min + step*i)) + ); + } + + setTimeout(process,0); + }; + + process(); +} + + // ============================================================ + // 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; + + sendStyledMessage(`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 h${headerLevel} 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 === "--align") return handleAlign(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."); + }); + +}); diff --git a/PinTool/PinTool.js b/PinTool/PinTool.js index 3f08f642c..2880ee014 100644 --- a/PinTool/PinTool.js +++ b/PinTool/PinTool.js @@ -1,28 +1,15 @@ // 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); - } -} + +const pintool ="1.0.5"; on("ready", () => { - const version = '1.0.4'; //version number set here + const version = '1.0.5'; //version number set here log('-=> PinTool v' + version + ' is loaded. Use !pintool --help for documentation.'); + //1.0.5 Created Named Space for the script //1.0.4 Huge update: 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 @@ -2093,6 +2080,142 @@ if(transformType.startsWith("imageto|")) } + + // ============================================================ + // ALIGN PINS + // ============================================================ + +function handleAlign(msg, args) +{ + if(!args.length) + return sendError("No alignment specified."); + + if(!msg.selected || msg.selected.length < 2) + return sendError("Select at least two pins."); + + const pins = msg.selected + .map(s => getObj("pin", s._id)) + .filter(Boolean); + + if(pins.length !== msg.selected.length) + return sendError("--align only operates on pins."); + + // -------- DEBUG: log positions before -------- + log("=== PinTool ALIGN BEFORE ==="); + pins.forEach(p => + log(`PIN ${p.id} X:${p.get("x")} Y:${p.get("y")}`) + ); + + const ops = args.map(a => a.toLowerCase()); + + const horizontal = ["left","right","hcenter","hdist"]; + const vertical = ["top","bottom","vcenter","vdist"]; + + const hOps = ops.filter(o => horizontal.includes(o)); + const vOps = ops.filter(o => vertical.includes(o)); + + if(hOps.length > 1) + return sendError("Conflicting horizontal alignments."); + + if(vOps.length > 1) + return sendError("Conflicting vertical alignments."); + + if(ops.includes("hdist") && pins.length < 3) + return sendError("hdist requires at least three pins."); + + if(ops.includes("vdist") && pins.length < 3) + return sendError("vdist requires at least three pins."); + + const queue = [...ops]; + + const process = () => + { + if(!queue.length) + { + // -------- DEBUG: log positions after -------- + log("=== PinTool ALIGN AFTER ==="); + pins.forEach(p => + log(`PIN ${p.id} X:${p.get("x")} Y:${p.get("y")}`) + ); + return; + } + + const op = queue.shift(); + + if(op === "left") + { + const x = Math.min(...pins.map(p=>p.get("x"))); + pins.forEach(p=>p.set("x",x)); + } + + else if(op === "right") + { + const x = Math.max(...pins.map(p=>p.get("x"))); + pins.forEach(p=>p.set("x",x)); + } + + else if(op === "top") + { + const y = Math.min(...pins.map(p=>p.get("y"))); + pins.forEach(p=>p.set("y",y)); + } + + else if(op === "bottom") + { + const y = Math.max(...pins.map(p=>p.get("y"))); + pins.forEach(p=>p.set("y",y)); + } + + else if(op === "hcenter") + { + const avg = Math.round( + pins.reduce((a,p)=>a+p.get("x"),0) / pins.length + ); + pins.forEach(p=>p.set("x",avg)); + } + + else if(op === "vcenter") + { + const avg = Math.round( + pins.reduce((a,p)=>a+p.get("y"),0) / pins.length + ); + pins.forEach(p=>p.set("y",avg)); + } + + else if(op === "hdist") + { + const sorted = [...pins].sort((a,b)=>a.get("x")-b.get("x")); + + const min = sorted[0].get("x"); + const max = sorted[sorted.length-1].get("x"); + + const step = (max-min)/(sorted.length-1); + + sorted.forEach((p,i)=> + p.set("x",Math.round(min + step*i)) + ); + } + + else if(op === "vdist") + { + const sorted = [...pins].sort((a,b)=>a.get("y")-b.get("y")); + + const min = sorted[0].get("y"); + const max = sorted[sorted.length-1].get("y"); + + const step = (max-min)/(sorted.length-1); + + sorted.forEach((p,i)=> + p.set("y",Math.round(min + step*i)) + ); + } + + setTimeout(process,0); + }; + + process(); +} + // ============================================================ // CONVERT MODE (tokens → handout) // ============================================================ @@ -2739,6 +2862,7 @@ if(transformType.startsWith("imageto|")) 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 === "--align") return handleAlign(msg, parts.slice(2)); if(cmd === "--help") return handleHelp(msg); @@ -2782,14 +2906,3 @@ if(cmd === "--library") }); }); - -{ - 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 57471f95c..581b64da7 100644 --- a/PinTool/script.json +++ b/PinTool/script.json @@ -1,8 +1,9 @@ { "name": "PinTool", "script": "PinTool.js", - "version": "1.0.4", - "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", + "version": "1.0.5", +"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": [], "modifies": { @@ -10,5 +11,5 @@ "pin": "write" }, "conflicts": [], - "previousversions": ["1.0.0","1.0.1","1.0.2","1.0.3"] -} + "previousversions": ["1.0.0","1.0.1","1.0.2","1.0.3","1.0.4"] +} \ No newline at end of file diff --git a/Token Reference/1.0.0/TokenReference.js b/Token Reference/1.0.0/TokenReference.js new file mode 100644 index 000000000..ad4127671 --- /dev/null +++ b/Token Reference/1.0.0/TokenReference.js @@ -0,0 +1,288 @@ +// Script: Token Reference +// By: Keith Curtis +// Contact: https://app.roll20.net/users/162065/keithcurtis + + +var TokenReference = TokenReference || (() => { + 'use strict'; + + const version = '1.0.0'; + log('-=> Token Reference v' + version + ' is loaded. Use !tokenref to send to chat'); + // 1.0.0 Debut + + + + const PAGE_NAME = 'Token Page'; + + // ========================= + // Centralized CSS + // ========================= + const CSS = { + container: 'background-image: url(https://files.d20.io/images/459209464/Gvxg3OZzRhp_4sK7NnZhXw/original.jpg);border:1px solid #444;border-radius:6px;padding:10px;max-width:420px;font-family:Arial, sans-serif;', + header: 'font-size:16px;font-weight:bold;color:#111;margin-bottom:8px;border-bottom:1px solid #555;padding-bottom:4px;', + row: 'margin:6px 0;color:#111;', + label: 'font-weight:bold;color:#111;', + image: 'display:block;float:right;margin:-20px -5px 4px 6px;border:none;max-width:100px;height:auto;', + link: 'color:#bf2489;text-decoration:none;font-weight:bold;', + linkInline: 'color:#bf2489!important;font-weight:bolder;text-decoration:none!important;padding:0; background-color:transparent!important;', + gmnotes: 'background-image:url("https://files.d20.io/images/480824890/ugCopgV2Prz1IOswa8XnfA/original.jpg?1774393520");border:1px solid #333;padding:6px;margin-top:6px;white-space:pre-wrap;color:#ccc;' + }; + + const isGM = (playerid) => playerIsGM(playerid); + + const getPage = () => findObjs({ + type: 'page', + name: PAGE_NAME + })[0]; + + const findTokenByName = (pageId, name) => { + const search = name.trim().toLowerCase(); + + return findObjs({ + type: 'graphic', + subtype: 'token', + _pageid: pageId + }).find(t => { + const n = t.get('name'); + return n && n.toLowerCase() === search; + }); + }; + + // ========================= + // LINK PROCESSOR (FIXED) + // ========================= + const processInlineLinks = (content) => { + if (!content || typeof content !== 'string') return content; + + let out = content; + + // --- STEP 0: Protect image markdown links --- + // Store them temporarily so we don't modify them + const imageLinks = []; + out = out.replace( + /\[([^\]]+)\]\((https?:\/\/[^)\s]+\.(?:png|jpg|jpeg|gif|webp))\)/gi, + (match) => { + imageLinks.push(match); + return `%%IMG_LINK_${imageLinks.length - 1}%%`; + } + ); + + // --- STEP 1: Convert Markdown links (non-image only, including API commands) --- + out = out.replace( + /\[([^\]\n]+)\]\(([^)\s]+)\)/gi, + (match, label, link) => { + const trimmedLink = link.trim(); + + // Skip image links (by extension) + if (/\.(png|jpg|jpeg|gif|webp|svg)$/i.test(trimmedLink)) { + return match; // leave unchanged + } + + // Build styled anchor (supports URLs AND API commands like !cmd) + return `${label.trim()}`; + } + ); + + // --- STEP 2: Normalize existing tags --- + out = out.replace( + /]*href=["']([^"']+)["'][^>]*>(.*?)<\/a>/gi, + (match, href, text) => { + const label = text && text.trim() ? text.trim() : href; + return `${label}`; + } + ); + + // --- STEP 3: Restore image markdown links --- + out = out.replace(/%%IMG_LINK_(\d+)%%/g, (match, i) => imageLinks[i]); + + return out; + }; + + // ========================= + // GM NOTES DECODER + // ========================= + // ========================= +// GM NOTES DECODER (with punctuation normalization) +// ========================= +const decodeGMNotes = (notes, playerid) => { + if (!notes) return ''; + + try { + + // --- Normalize “fancy” punctuation --- +let decoded = notes + // Fix %uXXXX Unicode sequences (common for curly quotes) + .replace(/%u2018|%u2019/g, "'") // curly single quotes → straight + .replace(/%u201C|%u201D/g, '"') // curly double quotes → straight + // Also fix literal curly quotes that somehow slipped through + .replace(/[‘’]/g, "'") + .replace(/[“”]/g, '"') + .replace(/–/g, '-') // en dash → hyphen + .replace(/—/g, '--') // em dash → double hyphen + .replace(/…/g, '...') // ellipsis → three dots + .replace(/[•◦]/g, '*') // bullets → asterisk + .replace(/[‛‟]/g, "'"); // rare single quotes → straight apostrophe + +decoded = decodeURIComponent(decoded); + + // Normalize line breaks + decoded = decoded.replace(/\\n/gi, '
    '); + + // Truncate for players only + if (!playerIsGM(playerid)) { + const splitIndex = decoded.indexOf('-----'); + if (splitIndex !== -1) { + decoded = decoded.substring(0, splitIndex); + } + } + + // Process links BEFORE stripping HTML + decoded = processInlineLinks(decoded); + + return decoded.trim(); + } catch (e) { + return processInlineLinks(notes); + } +}; + + // ========================= + // BUILD OUTPUT HTML + // ========================= + const buildOutput = (token, playerid) => { + const name = token.get('name') || 'Unnamed Token'; + const img = token.get('imgsrc'); + + const charId = token.get('represents'); + let charLink = 'None'; + + if (charId) { + const character = getObj('character', charId); + const cname = character.get('name'); + if (character) { + const url = `https://journal.roll20.net/character/${charId}`; +// Show actual character name for GMs, "Character Sheet" for PCs + const label = isGM(playerid) ? cname : 'Character Sheet'; + charLink = `${label}`; } + } + + // Pass playerid to decodeGMNotes + const gmnotes = decodeGMNotes(token.get('gmnotes'), playerid); + + let report = ` +
    + +
    ${name}
    +
    + ${charLink} +
    +
    +
    ${gmnotes || 'None'}
    +
    +
    + `.replace(/\r\n|\r|\n/g, "").trim(); + + return report; + }; + + +const showHelp = (playerid, who) => { + const help = ` +
    +
    TokenReference Help
    + +
    + Command:
    + !tokenref <token name> +
    + +
    + Displays a formatted reference card for a token on a page named + "${PAGE_NAME}". The primary use of this script is for + providing links in handouts. By putting the command into the + link of text in handout, you can send a reference to an + established PC or NPC to chat. +
    + +
    + Behavior:
    + • Matches token names exactly (case-insensitive)
    + • Shows token image and GM notes
    + • GM notes are truncated at ----- for players. This conforms with the Supernotes script behavior.
    + • Markdown-style links are converted to clickable links +
    + +
    + GM Features:
    + • Sees full GM notes
    + • Character link shows actual character name +
    + +
    + Player Features:
    + • Sees truncated notes (if applicable)
    + • Character link labeled "Character Sheet" + • If player does not have permissions, they cannot open the link. +
    + +
    + Example:
    + !tokenref Goblin Scout +
    +
    + `.replace(/\r\n|\r|\n/g, "").trim(); + + let whisperTo = who; + if (isGM(playerid)) whisperTo = 'GM'; + whisperTo = whisperTo.replace(/"/g, '\\"'); + + sendChat('TokenRef', `/w "${whisperTo}" ${help}`); +}; + + + // ========================= + // HANDLE INPUT + // ========================= + const handleInput = (msg) => { + if (msg.type !== 'api') return; +if (!msg.content.startsWith('!tokenref')) return; + +// Help command +if (msg.content.trim() === '!tokenref' || msg.content.includes('--help')) { + showHelp(msg.playerid, msg.who); + return; +} + const name = msg.content.replace('!tokenref ', '').trim(); + if (!name) return; + + const page = getPage(); + if (!page) { + sendChat('TokenRef', '/w gm Token Page not found.'); + return; + } + + const token = findTokenByName(page.id, name); + if (!token) { + sendChat('TokenRef', `/w gm No exact match found for "${name}".`); + return; + } + + // Pass playerid to buildOutput + const output = buildOutput(token, msg.playerid); + let whisperTo = msg.who; + if (isGM(msg.playerid)) whisperTo = 'GM'; // Roll20 wants "GM" as whisper + whisperTo = whisperTo.replace(/"/g, '\\"'); // escape quotes in player name + sendChat('TokenRef', `/w "${whisperTo}" ${output}`); + }; + + const registerEventHandlers = () => { + on('chat:message', handleInput); + }; + + return { + registerEventHandlers + }; +})(); + +on('ready', () => { + TokenReference.registerEventHandlers(); +}); \ No newline at end of file diff --git a/Token Reference/TokenReference.js b/Token Reference/TokenReference.js new file mode 100644 index 000000000..ad4127671 --- /dev/null +++ b/Token Reference/TokenReference.js @@ -0,0 +1,288 @@ +// Script: Token Reference +// By: Keith Curtis +// Contact: https://app.roll20.net/users/162065/keithcurtis + + +var TokenReference = TokenReference || (() => { + 'use strict'; + + const version = '1.0.0'; + log('-=> Token Reference v' + version + ' is loaded. Use !tokenref to send to chat'); + // 1.0.0 Debut + + + + const PAGE_NAME = 'Token Page'; + + // ========================= + // Centralized CSS + // ========================= + const CSS = { + container: 'background-image: url(https://files.d20.io/images/459209464/Gvxg3OZzRhp_4sK7NnZhXw/original.jpg);border:1px solid #444;border-radius:6px;padding:10px;max-width:420px;font-family:Arial, sans-serif;', + header: 'font-size:16px;font-weight:bold;color:#111;margin-bottom:8px;border-bottom:1px solid #555;padding-bottom:4px;', + row: 'margin:6px 0;color:#111;', + label: 'font-weight:bold;color:#111;', + image: 'display:block;float:right;margin:-20px -5px 4px 6px;border:none;max-width:100px;height:auto;', + link: 'color:#bf2489;text-decoration:none;font-weight:bold;', + linkInline: 'color:#bf2489!important;font-weight:bolder;text-decoration:none!important;padding:0; background-color:transparent!important;', + gmnotes: 'background-image:url("https://files.d20.io/images/480824890/ugCopgV2Prz1IOswa8XnfA/original.jpg?1774393520");border:1px solid #333;padding:6px;margin-top:6px;white-space:pre-wrap;color:#ccc;' + }; + + const isGM = (playerid) => playerIsGM(playerid); + + const getPage = () => findObjs({ + type: 'page', + name: PAGE_NAME + })[0]; + + const findTokenByName = (pageId, name) => { + const search = name.trim().toLowerCase(); + + return findObjs({ + type: 'graphic', + subtype: 'token', + _pageid: pageId + }).find(t => { + const n = t.get('name'); + return n && n.toLowerCase() === search; + }); + }; + + // ========================= + // LINK PROCESSOR (FIXED) + // ========================= + const processInlineLinks = (content) => { + if (!content || typeof content !== 'string') return content; + + let out = content; + + // --- STEP 0: Protect image markdown links --- + // Store them temporarily so we don't modify them + const imageLinks = []; + out = out.replace( + /\[([^\]]+)\]\((https?:\/\/[^)\s]+\.(?:png|jpg|jpeg|gif|webp))\)/gi, + (match) => { + imageLinks.push(match); + return `%%IMG_LINK_${imageLinks.length - 1}%%`; + } + ); + + // --- STEP 1: Convert Markdown links (non-image only, including API commands) --- + out = out.replace( + /\[([^\]\n]+)\]\(([^)\s]+)\)/gi, + (match, label, link) => { + const trimmedLink = link.trim(); + + // Skip image links (by extension) + if (/\.(png|jpg|jpeg|gif|webp|svg)$/i.test(trimmedLink)) { + return match; // leave unchanged + } + + // Build styled anchor (supports URLs AND API commands like !cmd) + return `${label.trim()}`; + } + ); + + // --- STEP 2: Normalize existing tags --- + out = out.replace( + /]*href=["']([^"']+)["'][^>]*>(.*?)<\/a>/gi, + (match, href, text) => { + const label = text && text.trim() ? text.trim() : href; + return `${label}`; + } + ); + + // --- STEP 3: Restore image markdown links --- + out = out.replace(/%%IMG_LINK_(\d+)%%/g, (match, i) => imageLinks[i]); + + return out; + }; + + // ========================= + // GM NOTES DECODER + // ========================= + // ========================= +// GM NOTES DECODER (with punctuation normalization) +// ========================= +const decodeGMNotes = (notes, playerid) => { + if (!notes) return ''; + + try { + + // --- Normalize “fancy” punctuation --- +let decoded = notes + // Fix %uXXXX Unicode sequences (common for curly quotes) + .replace(/%u2018|%u2019/g, "'") // curly single quotes → straight + .replace(/%u201C|%u201D/g, '"') // curly double quotes → straight + // Also fix literal curly quotes that somehow slipped through + .replace(/[‘’]/g, "'") + .replace(/[“”]/g, '"') + .replace(/–/g, '-') // en dash → hyphen + .replace(/—/g, '--') // em dash → double hyphen + .replace(/…/g, '...') // ellipsis → three dots + .replace(/[•◦]/g, '*') // bullets → asterisk + .replace(/[‛‟]/g, "'"); // rare single quotes → straight apostrophe + +decoded = decodeURIComponent(decoded); + + // Normalize line breaks + decoded = decoded.replace(/\\n/gi, '
    '); + + // Truncate for players only + if (!playerIsGM(playerid)) { + const splitIndex = decoded.indexOf('-----'); + if (splitIndex !== -1) { + decoded = decoded.substring(0, splitIndex); + } + } + + // Process links BEFORE stripping HTML + decoded = processInlineLinks(decoded); + + return decoded.trim(); + } catch (e) { + return processInlineLinks(notes); + } +}; + + // ========================= + // BUILD OUTPUT HTML + // ========================= + const buildOutput = (token, playerid) => { + const name = token.get('name') || 'Unnamed Token'; + const img = token.get('imgsrc'); + + const charId = token.get('represents'); + let charLink = 'None'; + + if (charId) { + const character = getObj('character', charId); + const cname = character.get('name'); + if (character) { + const url = `https://journal.roll20.net/character/${charId}`; +// Show actual character name for GMs, "Character Sheet" for PCs + const label = isGM(playerid) ? cname : 'Character Sheet'; + charLink = `${label}`; } + } + + // Pass playerid to decodeGMNotes + const gmnotes = decodeGMNotes(token.get('gmnotes'), playerid); + + let report = ` +
    + +
    ${name}
    +
    + ${charLink} +
    +
    +
    ${gmnotes || 'None'}
    +
    +
    + `.replace(/\r\n|\r|\n/g, "").trim(); + + return report; + }; + + +const showHelp = (playerid, who) => { + const help = ` +
    +
    TokenReference Help
    + +
    + Command:
    + !tokenref <token name> +
    + +
    + Displays a formatted reference card for a token on a page named + "${PAGE_NAME}". The primary use of this script is for + providing links in handouts. By putting the command into the + link of text in handout, you can send a reference to an + established PC or NPC to chat. +
    + +
    + Behavior:
    + • Matches token names exactly (case-insensitive)
    + • Shows token image and GM notes
    + • GM notes are truncated at ----- for players. This conforms with the Supernotes script behavior.
    + • Markdown-style links are converted to clickable links +
    + +
    + GM Features:
    + • Sees full GM notes
    + • Character link shows actual character name +
    + +
    + Player Features:
    + • Sees truncated notes (if applicable)
    + • Character link labeled "Character Sheet" + • If player does not have permissions, they cannot open the link. +
    + +
    + Example:
    + !tokenref Goblin Scout +
    +
    + `.replace(/\r\n|\r|\n/g, "").trim(); + + let whisperTo = who; + if (isGM(playerid)) whisperTo = 'GM'; + whisperTo = whisperTo.replace(/"/g, '\\"'); + + sendChat('TokenRef', `/w "${whisperTo}" ${help}`); +}; + + + // ========================= + // HANDLE INPUT + // ========================= + const handleInput = (msg) => { + if (msg.type !== 'api') return; +if (!msg.content.startsWith('!tokenref')) return; + +// Help command +if (msg.content.trim() === '!tokenref' || msg.content.includes('--help')) { + showHelp(msg.playerid, msg.who); + return; +} + const name = msg.content.replace('!tokenref ', '').trim(); + if (!name) return; + + const page = getPage(); + if (!page) { + sendChat('TokenRef', '/w gm Token Page not found.'); + return; + } + + const token = findTokenByName(page.id, name); + if (!token) { + sendChat('TokenRef', `/w gm No exact match found for "${name}".`); + return; + } + + // Pass playerid to buildOutput + const output = buildOutput(token, msg.playerid); + let whisperTo = msg.who; + if (isGM(msg.playerid)) whisperTo = 'GM'; // Roll20 wants "GM" as whisper + whisperTo = whisperTo.replace(/"/g, '\\"'); // escape quotes in player name + sendChat('TokenRef', `/w "${whisperTo}" ${output}`); + }; + + const registerEventHandlers = () => { + on('chat:message', handleInput); + }; + + return { + registerEventHandlers + }; +})(); + +on('ready', () => { + TokenReference.registerEventHandlers(); +}); \ No newline at end of file diff --git a/Token Reference/readme.md b/Token Reference/readme.md new file mode 100644 index 000000000..a8d48d7b4 --- /dev/null +++ b/Token Reference/readme.md @@ -0,0 +1,53 @@ +# TokenReference + +TokenReference is a lightweight Roll20 API script that allows users to quickly retrieve a formatted reference card for tokens on a designated page. + +Instead of searching through journals or the VTT, users can call up a token’s image, character link, and GM notes directly in chat with a simple command. Useful for embedding in journal command buttons. + +--- + +## Features + +- Displays a styled reference card in chat +- Includes: + - Token image + - Token name + - Character sheet link + - GM notes (formatted and cleaned) +- Supports Markdown-style links in GM notes +- Automatically converts links into clickable chat anchors +- Handles Unicode and encoded GM notes safely +- Player-safe output: + - Truncates GM notes at `-----` +- GM enhancements: + - Full GM notes visible + - Character link displays actual character name + +--- + +## Usage + +### Basic Command + +`!tokenref ` + +Displays the reference card for a token with the given name. + +- Matching is **case-insensitive** +- Requires an **exact name match** + +--- + +### Help Command + +`!tokenref --help` + +Displays usage instructions in chat. + +--- + +## Configuration + +### Token Page + +The script looks for tokens on a page named: **Token Page** \ No newline at end of file diff --git a/Token Reference/script.json b/Token Reference/script.json new file mode 100644 index 000000000..34638faca --- /dev/null +++ b/Token Reference/script.json @@ -0,0 +1,11 @@ +{ + "name": "TokenReference", + "script": "TokenReference.js", + "version": "1.0.0", + "description": "Displays a styled reference card in chat for tokens on a designated page, including image, character link, and formatted GM notes. ", + "authors": "Keith Curtis", + "roll20userid": "162065", + "dependencies": [], + "conflicts": [], + "previousversions": ["1.0.0"] +} \ No newline at end of file diff --git a/Wiki/1.0.0/Wiki.js b/Wiki/1.0.0/Wiki.js new file mode 100644 index 000000000..d7e70349d --- /dev/null +++ b/Wiki/1.0.0/Wiki.js @@ -0,0 +1,2239 @@ +// Script: Wiki +// By: Keith Curtis +// Contact: https://app.roll20.net/users/162065/keithcurtis + +const wiki = (() => { + 'use strict'; + const version = '1.0.0'; //version number set here + log('-=> Wiki v' + version + ' is loaded. Use "!wiki" to start.'); + //1.0.0 Debut + + const scriptName = "Wiki"; + const VIEWPORT_HANDOUT_NAME = "Wiki - GM"; + const WIKI_HANDOUT_NAME = "Wiki - Player"; + + /* ============================================================ + * STATE + * ============================================================ */ + + const STATE_DEFAULTS = () => ( + { + mode: "handout", + selectedHandout: null, + selectedHeaderLevel: 1, + showBelow: false, + currentPage: null, + navBack: [], + navForward: [], + filters: + { + handout: { h5: [], h6: [] }, + pins: { h5: [], h6: [] } + }, + keywordData: + { + handout: { h5: [], h6: [] }, + pins: { h5: [], h6: [] } + }, + currentItemType: null, + currentItemId: null, + currentList: [], + currentIndex: -1, + _lastPinId: null, + _handoutSelectorCache: null, + displayHTML: "" + }); + + const initializeState = () => + { + state.Wiki = state.Wiki || {}; + state.Wiki.gm = state.Wiki.gm || STATE_DEFAULTS(); + state.Wiki.player = state.Wiki.player || STATE_DEFAULTS(); + + const ensurePartition = (p) => + { + p.filters = p.filters || {}; + p.filters.handout = p.filters.handout || { h5:[], h6:[] }; + p.filters.pins = p.filters.pins || { h5:[], h6:[] }; + p.keywordData = p.keywordData || {}; + p.keywordData.handout = p.keywordData.handout || { h5:[], h6:[] }; + p.keywordData.pins = p.keywordData.pins || { h5:[], h6:[] }; + if(!Array.isArray(p.navBack)) p.navBack = []; + if(!Array.isArray(p.navForward)) p.navForward = []; + if(!Array.isArray(p.currentList)) p.currentList = []; + if(typeof p.currentIndex !== "number") p.currentIndex = -1; + if(typeof p._lastPinId === 'undefined') p._lastPinId = null; + if(typeof p._handoutSelectorCache === 'undefined') p._handoutSelectorCache = null; + p.displayHTML = p.displayHTML || ""; + }; + + ensurePartition(state.Wiki.gm); + ensurePartition(state.Wiki.player); + }; + + const getState = (isGM = true) => isGM ? state.Wiki.gm : state.Wiki.player; + + /* ============================================================ + * CONSTANTS + * ============================================================ */ + + const WIKI_HOME_HANDOUT_NAME = "Wiki Home"; + const WIKI_HOME_DEFAULT_TEXT = ` +

    Welcome to the Wiki

    +

    This is your campaign Wiki home page. Edit this handout to customize it.

    +

    You can use this handout as a landing page for players, with links to other handouts, lore, and campaign information.

    +

    Getting Started

    +

    To navigate the Wiki:

    +
      +
    • Use the HANDOUTS button to switch to handout browsing mode.
    • +
    • Use the PINS button to browse map pins on the current page.
    • +
    • Select a handout from the left panel to read it.
    • +
    • Use the header level buttons (H1–H4) to filter by section depth.
    • +
    +`; + + /* ============================================================ + * CSS — module-level constant, never rebuilt + * ============================================================ */ + + const CSS = + { + container: "width:100%; min-height:600px;font-family:Arial, sans-serif;border:4px solid #422c26;", + header: "background:#422c26; color:#ddd; font-family: Nunito, Arial, sans-serif; font-weight:bold; text-align:left; font-size:20px; padding:4px;", + layoutTable: "width:100%; border-collapse:collapse; table-layout:fixed;", + leftPanel: "background:#422c26; width:220px; vertical-align:top; padding:4px; box-sizing:border-box;", + rightPanel: "vertical-align:top; padding:6px; box-sizing:border-box;", + + modeRow: "display:table; width:100%; border-collapse:separate; border-spacing:4px 0; margin-bottom:4px; padding:0;", + modeRowButton: "display:table-cell; width:1%; font-weight:bold; text-align:center; padding:6px 8px; border:none; border-radius:4px; background:#6B3728; text-decoration:none; color:#ddd; font-size:12px; box-sizing:border-box; cursor:pointer;", + + headerRow: "display:table; width:100%; margin-bottom:4px;", + headerRowButton: "display:table-cell; width:1%; text-align:center; padding:4px; border:1px solid #444; background:#cfb080; text-decoration:none; color:#222; font-size:12px; box-sizing:border-box;", + + handoutButton: "display:block; width:calc(100% - 15px); margin:4px 0; padding:6px; border:1px solid #444; border-radius:4px; background:#cfb080; text-decoration:none; color:black; font-size:12px; box-sizing:border-box;", + listButton: "display:block; width:100%; margin:0; padding:4px; border:1px solid #444; border-radius:4px; background:#cfb080; text-decoration:none; color:black; font-size:12px; box-sizing:border-box;", + listButtonBase: "display:block; width:180px; margin:2px 0; padding:4px; border-radius:4px; text-decoration:none; color:#222; font-size:12px; box-sizing:border-box;", + + pinRowTable: "width:100%; border-collapse:collapse; margin:0px 0;border-style:none;", + pinMainCell: "width:100px; padding:0 2px 0 0;border-style:none;", + pinPingCell: "width:28px; height:15px; text-align:center; padding:0; font-family:pictos;border-style:none;", + + pinPingButton: "display:inline-block; width:10px; background:transparent; border:none; font-family:pictos; font-size:16px; text-decoration:none; color:#cfb080; cursor:pointer;", + pinPingButtonGM: "display:inline-block; width:10px; background:transparent; border:none; font-family:pictos; font-size:16px; text-decoration:none; color:#ddd; cursor:pointer;", + + h1Button: "background:#a47148; border:1px solid #8c5e3b; font-weight:bolder; margin-left:0px;", + h2Button: "background:#c28b5a; border:1px solid #a47148; font-weight:bold; margin-left:5px;", + h3Button: "background:#d9a873; border:1px solid #c28b5a; font-weight:normal; margin-left:10px;", + h4Button: "background:#f0c98f; border:1px solid #d9a873; font-weight:lighter; margin-left:15px;", + + h1ButtonGM: "background:#666; border:1px solid #666; font-weight:bolder; margin-left:0px;", + h2ButtonGM: "background:#777; border:1px solid #777; font-weight:bold; margin-left:5px;", + h3ButtonGM: "background:#888; border:1px solid #888; font-weight:normal; margin-left:10px;", + h4ButtonGM: "background:#999; border:1px solid #999; font-weight:lighter; margin-left:15px;", + + keywordRow: "text-align:left; margin:2px 0;color:#eee; font-weight:bold; background-color:#422c26;", + keywordButton: "display:inline-block; padding:1px 2px; margin:1px; border-radius:4px; border-style:none; background:#f0c98f; color:#111; font-size:10px; cursor:pointer;", + + controlBar: "background:#2e1f1a; padding:6px; border-bottom:1px solid #111; text-align:center;", + controlButton: "display:inline-block; margin:0 3px; padding:4px 8px; background:#6B3728; color:#ddd !important; border-radius:4px; text-decoration:none; font-size:12px;font-weight:bold;", + + messageContainer: 'background-color:#222; color:#ccc; Border: solid 1px #444; border-radius:5px; padding:10px; position:relative; top:-15px; left:-5px; font-family: Nunito, Arial, sans-serif;', + messageTitle: 'color:#ddd; margin-bottom:13px; font-size:16px; text-transform: capitalize; text-align:center;', + messageButton: 'background:#444; color:#ccc; border-radius:4px; padding:2px 6px; margin-right:2px; display:inline-block; vertical-align:middle' + }; + + const getCSS = () => CSS; + + /* ============================================================ + * UTILITIES + * ============================================================ */ + + const getPageForPlayer = (playerid) => + { + if(!playerid) return Campaign().get('playerpageid'); + const player = getObj('player', playerid); + if(!player) return Campaign().get('playerpageid'); + + if(playerIsGM(playerid)) + return player.get('lastpage') || Campaign().get('playerpageid'); + + const psp = Campaign().get('playerspecificpages'); + if(psp && psp[playerid]) return psp[playerid]; + + return Campaign().get('playerpageid'); + }; + + const withTimeout = (promise, ms = 5000, label = '') => + Promise.race([ + promise, + new Promise((_, reject) => + setTimeout(() => reject(new Error(`Timeout: ${label}`)), ms) + ) + ]); + + const sendStyledMessage = (titleOrMessage, messageOrUndefined, isPublic = false) => + { + const css = getCSS(); + let title, message; + + if(messageOrUndefined === undefined) + { + title = scriptName; + message = titleOrMessage; + } + else + { + title = titleOrMessage || scriptName; + message = messageOrUndefined; + } + + message = String(message).replace( + /\[([^\]]+)\]\(([^)]+)\)/g, + (_, label, command) => + `${label}` + ); + + const html = + `
    +
    ${title}
    + ${message} +
    `; + + sendChat( + scriptName, + `${isPublic ? "" : "/w gm "}${normalizeForChat(html)}`, + null, + { noarchive: true } + ); + }; + + const makeButton = (label, command, style) => `${label}`; + const normalizeForChat = (html) => html.replace(/\r?\n/g, ''); + + const helpButton = `?`; + const homeButton = ``; + + /* ============================================================ + * HELP HANDOUT + * ============================================================ */ + + const WIKI_HELP_NAME = "Help: Wiki"; + const WIKI_HELP_AVATAR = "https://files.d20.io/images/470559564/QxDbBYEhr6jLMSpm0x42lg/original.png?1767857147"; + +const WIKI_HELP_TEXT = ` +

    Wiki Interface Guide

    + +

    +The Wiki provides a unified interface for browsing campaign +information. Instead of opening multiple journal entries or searching through +pins on the map, the Wiki lets you navigate everything from one panel using +buttons and filters. +

    + +

    +The Wiki has two separate handouts: +

    + +
      +
    • Wiki - GM — the full GM interface, accessible only to the GM
    • +
    • Wiki - Player — the player-facing interface, shared with all players
    • +
    + +

    +Both are updated automatically when you interact with the Wiki. +The GM sees all content including GM Notes; players see only content from +handouts shared with them, and only the player-facing portion of pin descriptions. +

    + +
    + +

    Getting Started

    + +

    +Type !wiki in chat to open the Wiki. A link to the +Wiki - GM handout will be whispered to you. Players who type +!wiki receive a link to Wiki - Player. +

    + +

    +The interface is divided into two main areas: +

    + +
      +
    • Navigation Panel — the left panel, listing items you can open
    • +
    • Content Panel — the right panel, showing the currently viewed content
    • +
    + +
    + +

    Wiki Home

    + +

    +Typing !wiki always returns to the Wiki Home handout. +The home button () in the top-right corner of the +interface does the same. Navigating home clears the Back and Forward history. +

    + +

    +Wiki Home is a regular handout shared with all players. +Edit it freely to create a campaign landing page. Links to other handouts +inside Wiki Home will open directly in the Wiki panel when clicked. +

    + +

    Background Image

    + +

    +You can set a background image for the entire Wiki interface by adding an +image URL as a tag on the Wiki Home handout. The URL must begin with +https://. If multiple URL tags are present, the first one +is used. The background will tile across the interface container. +

    + +
    + +

    Handout Mode

    + +

    +Handout Mode lets you read handouts in the content panel. +Select a handout using the chooser button at the top of the navigation panel. +The currently active mode button is outlined. +

    + +

    +When a handout is selected, its full contents are displayed and all headers +appear in the navigation list. Click any header button to jump to that section. +The currently viewed section is outlined in the navigation list. +

    + +

    Header Level Buttons

    + +
      +
    • All — shows the entire handout and lists all headers at the highest available level
    • +
    • H1–H4 — filters the navigation list to show only headers at that level
    • +
    • { — when active, also shows headers below the selected level
    • +
    + +

    GM Notes Headers

    + +

    +For the GM, headers from the handout's GM Notes field also +appear in the navigation list, shown in grey to distinguish them from the +brown Notes headers. Clicking a grey header loads that GM Notes section into +the content panel. In the content panel, Notes and GM Notes content are +separated by a horizontal rule. +

    + +

    Handout Avatars

    + +

    +If a handout has an avatar image set, it is displayed at the top of the +content panel when viewing the full handout in All mode. +

    + +

    Handout Links

    + +

    +Links to other Roll20 handouts inside your content are automatically +rewritten so that clicking them opens the target handout directly in the +Wiki panel, rather than in a separate browser tab. +

    + +

    Using Keywords (H5–H6)

    + +

    +Keywords are optional tags used to filter sections. +Create them by adding H5 or H6 headers +inside a handout. +

    + +

    Example:

    + +
    +H2  Abandoned Mine
    +H5  dungeon
    +H6  goblins
    +
    + +

    +Clicking a keyword button filters the navigation list to show only sections +containing that keyword. Multiple keywords can be active at once. +Use Clear All to remove active filters. +

    + +
    + +

    Pin Mode

    + +

    +Pin Mode lists all map pins on the current page, sorted alphabetically. +The currently active mode button is outlined. The currently selected pin +is outlined in the navigation list. +

    + +

    +Selecting a pin loads its content into the content panel. +Switching back to Handout Mode and then returning to Pin Mode will +restore the last viewed pin automatically. +

    + +

    Where Pin Content Comes From

    + +
      +
    • Direct notes — text stored directly on the pin
    • +
    • A linked handout section — content pulled from a specific header in a handout, including GM Notes headers
    • +
    + +

    Player Visibility in Pin Mode

    + +

    +For pins linked to a handout, content is separated using a +blockquote. Players see only the content inside the +blockquote. Everything after it is GM-only. The GM sees all content +with a horizontal rule separating the two sections. +

    + +

    +Pins without a blockquote show no content to players. +

    + +

    +Use !wiki --audit-pins to scan the current page for +player-visible linked pins that are not correctly configured. Use the +Fix and Fix All buttons in the audit +report to correct them automatically. +

    + +

    Ping Buttons

    + +

    +Each pin entry in the navigation list includes two +@ buttons. +The same buttons also appear in the content panel header when a pin is selected. +

    + +
      +
    • Gold @ — pings the pin location for all players
    • +
    • Grey @ — pings the pin location for the GM only
    • +
    + +
    + +

    Content Panel Buttons

    + +

    +The control bar above the content panel provides navigation and action buttons. +

    + +
      +
    • ◀ Back — returns to the previously viewed handout or section (GM and players)
    • +
    • Forward ▶ — moves forward through history after going Back (GM and players)
    • +
    • ✕ History — clears all Back and Forward history (GM and players)
    • +
    • Previous / Next — steps through the filtered navigation list sequentially
    • +
    • Edit — opens the source handout for editing (GM only)
    • +
    • Send to Chat — sends the current content to GM chat. Does not filter GM-only content. (GM only)
    • +
    • Pintool — opens the Pintool interface if installed, in Pin Mode (GM only)
    • +
    + +

    +Back and Forward track navigation across different handouts and sections. +Previous and Next step through the current filtered list without affecting history. +GM and player Back/Forward histories are tracked independently. +

    + +
    + +

    Player Access

    + +

    +Players type !wiki in chat to receive a link to +Wiki - Player. The interface updates automatically when +they interact with it. Players have their own independent navigation history. +

    + +

    +Players can see handouts that have been shared with them via Roll20's +journal permissions. The GM can also grant access to any handout by +tagging it wiki+, regardless of journal permissions. +

    + +
    + +

    Handout Tags

    + +
      +
    • wiki+ — makes a handout visible to players in the Wiki chooser, regardless of journal permissions
    • +
    • wiki- — hides a handout from the Wiki chooser entirely. The Wiki - GM and Wiki - Player interface handouts are tagged this way automatically.
    • +
    + +

    +Tags are set in the handout's Edit mode using the Tags field. +

    + +
    + +

    Commands Reference

    + +
      +
    • !wiki — open the Wiki and go to Wiki Home
    • +
    • !wiki --help — open this help document
    • +
    • !wiki --audit-pins — scan current page for misconfigured player-visible pins
    • +
    + +
    + +

    Tips for Organizing Your Campaign

    + +
      +
    • Use H1–H4 in handouts for structure and navigation.
    • +
    • Use H5–H6 as keyword tags for filtering.
    • +
    • Keep keywords short and consistent across handouts.
    • +
    • Use blockquotes in linked pin sections to mark the player/GM boundary.
    • +
    • Set handout avatars to give locations and topics a visual identity.
    • +
    • Edit Wiki Home with links to your most-used handouts as a campaign dashboard.
    • +
    • Add an image URL as a tag on Wiki Home to set a background texture for the interface.
    • +
    • Use wiki+ to share specific handouts with players without changing journal permissions.
    • +
    • Use wiki- to hide utility or system handouts from the chooser.
    • +
    +`; + + function handleWikiHelp(msg) + { + if(msg.type !== "api") return; + + let handout = findObjs({ _type: "handout", name: WIKI_HELP_NAME })[0]; + + if(!handout) + { + handout = createObj("handout", + { + name: WIKI_HELP_NAME, + archived: false, + avatar: WIKI_HELP_AVATAR, + }); + } + + handout.set("avatar", WIKI_HELP_AVATAR); + handout.set("notes", WIKI_HELP_TEXT); + + const link = `http://journal.roll20.net/handout/${handout.get("_id")}`; + const box = ``; + + sendChat("Wiki", `/w gm ${box}`, null, { noarchive: true }); + } + + /* ============================================================ + * WIKI HOME + * ============================================================ */ + +const getWikiBackgroundURL = () => +{ + const homeHandout = findObjs({ _type: 'handout', name: WIKI_HOME_HANDOUT_NAME })[0]; + if(!homeHandout) return null; + + let tags = []; + try + { + const raw = homeHandout.get('tags'); + if(Array.isArray(raw)) tags = raw; + else if(typeof raw === 'string' && raw) tags = JSON.parse(raw); + } + catch(e) {} + + return tags.find(t => /^https?:\/\//i.test(t)) || null; +}; + + const getOrCreateWikiHome = () => + { + let handout = findObjs({ _type: 'handout', name: WIKI_HOME_HANDOUT_NAME })[0]; + + if(!handout) + { + handout = createObj('handout', + { + name: WIKI_HOME_HANDOUT_NAME, + inplayerjournals: 'all', + archived: false + }); + + if(handout) handout.set('notes', WIKI_HOME_DEFAULT_TEXT); + } + + return handout; + }; + + /* ============================================================ + * LINK REWRITING + * ============================================================ */ + + const rewriteHandoutLinks = (html) => + { + if(!html) return html; + + html = html.replace( + /href="http:\/\/journal\.roll20\.net\/handout\/([^"#/]+)\/?(?:#([^"]+))?"/g, + (match, handoutId, anchor) => + { + if(anchor) + { + const decoded = decodeURIComponent(anchor); + return `href="!wiki --selectHandout ${handoutId} --show ${encodeURIComponent(decoded)}"`; + } + return `href="!wiki --selectHandout ${handoutId}"`; + } + ); + + html = html.replace( + /href="(https:\/\/app\.roll20\.net\/compendium\/[^"]+)"/g, + (match, url) => + { + try { return `href="${decodeURIComponent(url)}"`; } + catch(e) { return `href="${url.replace(/%27/g,"'").replace(/%20/g," ").replace(/%28/g,"(").replace(/%29/g,")")}"` ; } + } + ); + + return html; + }; + + /* ============================================================ + * CORE ASYNC HELPERS + * ============================================================ */ + + const getHandoutAvatarHTML = (handoutId) => + { + const handout = getObj('handout', handoutId); + if(!handout) return ""; + + const avatar = handout.get('avatar'); + if(!avatar || avatar === '' || avatar === 'https://s3.amazonaws.com/files.d20.io/images/4277467/iKYSQhLKGRCLZuyBbZHbeA/thumb.jpg?1401938539') + return ""; + + return ``; + }; + + const getHandoutSectionHTML = (handoutId, headerText = null, field = 'notes', isGM = false) => + { + return withTimeout(new Promise(resolve => + { + const handout = getObj('handout', handoutId); + if(!handout) return resolve(null); + + handout.get(field, notes => + { + if(!notes) notes = ""; + + if(isGM && field !== 'gmnotes') + { + const assembleSection = (notesContent, gmNotesContent) => + { + if(!notesContent && !gmNotesContent) return null; + + let result = notesContent; + + if(/<\/blockquote>/i.test(result)) + { + result = result.replace( + /(<\/blockquote>)([\s\S]+)?/i, + (m, closing, after) => closing + (after ? `
    ${after}` : '') + ); + } + + const cleanGmNotes = (gmNotesContent || "") + .replace(/\r?\n/g, '') + .replace(/^null$/i, '') + .trim(); + + if(cleanGmNotes) result += `
    ${cleanGmNotes}`; + + return result.replace(/\r?\n/g, ''); + }; + + withTimeout(new Promise(resolveGM => + { + handout.get('gmnotes', gmNotes => + { + gmNotes = (gmNotes && gmNotes !== 'undefined') ? gmNotes : ""; + resolveGM(gmNotes); + }); + }), 1000, `gmnotes ${handoutId}`) + .then(gmNotes => + { + if(!headerText || headerText === '__all__') + return resolve(assembleSection(notes, gmNotes)); + + const section = extractSection(notes, headerText); + const gmSection = gmNotes ? extractSection(gmNotes, headerText) : ""; + + if(!section && !gmSection) return resolve(null); + + resolve(assembleSection(section || "", gmSection || "")); + }) + .catch(e => + { + log(`Wiki: gmnotes timeout for ${handoutId}: ${e}`); + + if(!headerText || headerText === '__all__') + return resolve(notes.replace(/\r?\n/g, '') || null); + + const section = extractSection(notes, headerText); + resolve(section ? section.replace(/\r?\n/g, '') : null); + }); + } + else + { + const processContent = (content) => + { + if(!content) return null; + + if(!isGM && /<\/blockquote>/i.test(content)) + { + const blockquoteEnd = content.search(/<\/blockquote>/i); + return content.slice(0, blockquoteEnd + ''.length) + .replace(/\r?\n/g, ''); + } + + return content.replace(/\r?\n/g, ''); + }; + + if(!headerText || headerText === '__all__') + return resolve(processContent(notes)); + + resolve(processContent(extractSection(notes, headerText))); + } + }); + }), 5000, `getHandoutSectionHTML ${handoutId}`) + .catch(e => + { + log(`Wiki: getHandoutSectionHTML timeout for ${handoutId}: ${e}`); + return null; + }); + }; + + /* ============================================================ + * NAVIGATION STATE + * ============================================================ */ + + const pushNavState = (destinationHandoutId, isGM = true) => + { + const s = getState(isGM); + + if(!s.currentItemType || !s.selectedHandout) return; + + const top = s.navBack[s.navBack.length - 1]; + if(top && top.handoutId === s.selectedHandout && top.headerText === s.currentItemId) return; + + s.navBack.push( + { + handoutId: s.selectedHandout, + headerText: s.currentItemId || null + }); + + s.navForward = []; + }; + + /* ============================================================ + * PARSING & EXTRACTION + * ============================================================ */ + + const normalizeWord = (word) => word.toLowerCase().replace(/[^\w]/g, ''); + + const parseArgs = (content) => + { + const args = {}; + const regex = /--([^\s]+)(?:\s+([^]*?))?(?=\s+--|$)/g; + let match; + + while((match = regex.exec(content)) !== null) + { + const key = match[1]; + let raw = (match[2] || "").trim(); + + if(raw.startsWith('"') && raw.endsWith('"')) raw = raw.slice(1, -1); + + args[key] = raw || true; + } + + return args; + }; + + const extractHeaders = (html, level) => + { + const regex = new RegExp(`]*>([\\s\\S]*?)<\\/h${level}>`, 'gi'); + const results = []; + let match; + + while((match = regex.exec(html)) !== null) + results.push(match[1].replace(/<[^>]+>/g, '').trim()); + + return results; + }; + + const getFilteredHeaders = (html, level, stateObj) => + { + let headers = extractHeaders(html, level); + const f = stateObj.filters.handout; + const levelKey = level === 5 ? 'h5' : 'h6'; + + if(!f[levelKey].length) return headers; + + return headers.filter(h => + f[levelKey].every(word => normalizeWord(h).includes(word)) + ); + }; + + const extractSection = (content, headerText) => + { + if(!content) return null; + + const headerRegex = /<(h[1-6])\b[^>]*>([\s\S]*?)<\/\1>/gi; + let match; + + while((match = headerRegex.exec(content)) !== null) + { + const level = parseInt(match[1][1], 10); + const stripped = match[2].replace(/<[^>]+>/g, '').trim(); + + if(stripped === headerText) + { + const start = match.index; + const remainder = content.slice(headerRegex.lastIndex); + const stopRegex = new RegExp(` + { + const headers = extractHeaders(html, level); + const words = new Set(); + + headers.forEach(text => + { + const cleaned = text + .replace(/ /gi, ' ') + .replace(/\u00A0/g, ' ') + .replace(/\s+/g, ' ') + .trim(); + + cleaned.split(' ').forEach(w => + { + const n = normalizeWord(w); + if(n) words.add(n); + }); + }); + + return Array.from(words).sort(); + }; + + const getHighestHeaderLevel = (html) => + { + for(let i = 1; i <= 4; i++) + { + if(new RegExp(` +{ + const s = getState(isGM); + const pageid = getPageForPlayer(playerid); + if(!pageid) return; + + const pins = findObjs({ _type: 'pin', _pageid: pageid }); + const h5 = new Set(); + const h6 = new Set(); + + const processedHandouts = new Set(); + + for(const p of pins) + { + if(!p.get('link')) continue; + + const handoutId = p.get('link'); + if(processedHandouts.has(handoutId)) continue; + processedHandouts.add(handoutId); + + const handout = getObj('handout', handoutId); + if(!handout) continue; + + const content = await withTimeout(new Promise(resolve => + { + handout.get('notes', notes => + { + resolve((notes && notes !== 'undefined') ? notes : ""); + }); + }), 3000, `buildPinKeywords notes ${handoutId}`).catch(() => ""); + + extractKeywords(content, 5).forEach(w => h5.add(w)); + extractKeywords(content, 6).forEach(w => h6.add(w)); + } + + s.keywordData.pins.h5 = Array.from(h5).sort(); + s.keywordData.pins.h6 = Array.from(h6).sort(); +}; + +const filterPins = async (pins, isGM = true) => +{ + const s = getState(isGM); + const result = []; + const contentCache = {}; + + for(const p of pins) + { + if(p.get('linkType') !== 'handout') + { + if(!s.filters.pins.h5.length && !s.filters.pins.h6.length) + result.push(p); + continue; + } + + const handoutId = p.get('link'); + const subHeader = p.get('subLink'); + const field = p.get('subLinkType') === 'headerGM' ? 'gmnotes' : 'notes'; + const f = s.filters.pins; + + if(!f.h5.length && !f.h6.length) + { + result.push(p); + continue; + } + + // Fetch the entire handout field to search within it + const cacheKey = `${handoutId}:${field}`; + if(!contentCache[cacheKey]) + { + const handout = getObj('handout', handoutId); + if(handout) + { + contentCache[cacheKey] = await withTimeout(new Promise(resolve => + { + handout.get(field, notes => + { + resolve((notes && notes !== 'undefined') ? notes : ""); + }); + }), 3000, `filterPins ${field} ${handoutId}`).catch(() => ""); + } + else + { + contentCache[cacheKey] = ""; + } + } + + // Extract just the pinned section from the full handout content + const fullContent = contentCache[cacheKey]; + const sectionContent = subHeader ? (extractSection(fullContent, subHeader) || "") : fullContent; + + const h5Words = extractKeywords(sectionContent, 5); + const h6Words = extractKeywords(sectionContent, 6); + + const passH5 = !f.h5.length || f.h5.some(x => h5Words.includes(x)); + const passH6 = !f.h6.length || f.h6.some(x => h6Words.includes(x)); + + if(passH5 && passH6) result.push(p); + } + + return result; +}; + /* ============================================================ + * PIN LIST BUILDER + * ============================================================ */ + +const buildPinList = async (playerid, isGM = true) => +{ + const s = getState(isGM); + const css = getCSS(); + const pageid = getPageForPlayer(playerid); + s.currentPage = pageid; + + if(!pageid) return "No pins found."; + + let pins = findObjs({ _type: 'pin', _pageid: pageid }) + .filter(p => isGM || p.get('visibleTo') === 'all'); + + if(!pins.length) return "No pins found."; + + pins = await filterPins(pins, isGM); + if(!pins.length) return "No pins found."; + + pins.sort((a, b) => + { + const titleA = (a.get('title') || a.get('subLink') || "(unnamed)") + .replace(/ /gi, ' ').replace(/\u00A0/g, ' ').trim().toLowerCase(); + const titleB = (b.get('title') || b.get('subLink') || "(unnamed)") + .replace(/ /gi, ' ').replace(/\u00A0/g, ' ').trim().toLowerCase(); + return titleA.localeCompare(titleB, undefined, { sensitivity: 'base' }); + }); + + let html = ""; + let orderedPins = []; + + for(const p of pins) + { + orderedPins.push(p.id); + + const title = p.get('title') || p.get('subLink') || "(unnamed)"; + const isActive = s.currentItemType === "pin" && s.currentItemId === p.id; + const activeStyle = isActive ? " outline:2px solid #ddd; outline-offset:1px;" : ""; + + const mainButton = makeButton(title, `!wiki --show-pin ${p.id}`, css.listButton + activeStyle); + const pingButton = makeButton("@", `!wiki --ping-pin ${p.id}`, css.pinPingButton); + const pingButtonGM = makeButton("@", `!wiki --ping-pin-gm ${p.id}`, css.pinPingButtonGM); + + html += `
    ${mainButton}${pingButton}${isGM ? pingButtonGM : ""}
    `; + } + + s.currentList = orderedPins; + return html; +}; + + /* ============================================================ + * UI BUILDERS + * ============================================================ */ + + const buildHandoutSelector = (isGM = true) => + { + const css = getCSS(); + const s = getState(isGM); + + if(!s._handoutSelectorCache) + { + s._handoutSelectorCache = findObjs({ _type: 'handout' }) + .filter(h => + { + let tags = []; + try + { + const raw = h.get('tags'); + if(Array.isArray(raw)) tags = raw; + else if(typeof raw === 'string' && raw) tags = JSON.parse(raw); + } + catch(e) {} + + if(tags.includes('wiki-')) return false; + return isGM || h.get('inplayerjournals') || tags.includes('wiki+'); + }) + .sort((a, b) => a.get('name').localeCompare(b.get('name'))) + .map(h => ({ name: h.get('name'), id: h.id })); + } + + const query = "?{Select Handout|" + + s._handoutSelectorCache.map(h => `${h.name},${h.id}`).join("|") + + "}"; + + const label = s.selectedHandout ? + getObj('handout', s.selectedHandout)?.get('name') : + "Choose Handout"; + + return makeButton(label, `!wiki --selectHandout ${query}`, css.handoutButton); + }; + +const buildModeRow = (isGM = true) => +{ + const css = getCSS(); + const s = getState(isGM); + const active = "outline:2px solid #ddd; outline-offset:1px;"; + + return `
    + ${makeButton("HANDOUTS", "!wiki --mode handout", css.modeRowButton + (s.mode === "handout" ? active : ""))} + ${makeButton("PINS", "!wiki --mode pins", css.modeRowButton + (s.mode === "pins" ? active : ""))} +
    `; +}; + + const buildHeaderRow = (isGM = true) => + { + const css = getCSS(); + const s = getState(isGM); + let buttons = makeButton(`All`, `!wiki --level 0`, css.headerRowButton); + + for(let i = 1; i <= 4; i++) + buttons += makeButton(`h${i}`, `!wiki --level ${i}`, css.headerRowButton); + + const belowLabel = s.showBelow ? + `}` : + `{`; + + buttons += makeButton(belowLabel, `!wiki --below ${s.showBelow ? "false" : "true"}`, css.headerRowButton); + + return `
    ${buttons}
    `; + }; + + const buildKeywordRow = (level, isGM = true) => + { + try + { + const css = getCSS(); + const s = getState(isGM); + const mode = s.mode === "pins" ? "pins" : "handout"; + + s.keywordData[mode] = s.keywordData[mode] || {}; + s.filters[mode] = s.filters[mode] || {}; + s.keywordData[mode][level] = s.keywordData[mode][level] || []; + s.filters[mode][level] = s.filters[mode][level] || []; + + const words = s.keywordData[mode][level]; + let html = ""; + + if(words.length) + { + html += `${level} Keywords: `; + html += makeButton("Clear All", `!wiki --clear-${level}`, css.keywordButton); + + words.forEach(k => + { + const active = s.filters[mode][level].includes(k); + html += makeButton(k, `!wiki --filter-${level} ${k}`, + `${css.keywordButton}${active ? ' font-weight:bold; background:#aaa;' : ''}`); + }); + } + + return `
    ${html}
    `; + } + catch(e) + { + log(`Wiki buildKeywordRow ERROR: ${e}`); + return ""; + } + }; + +const buildHeaderList = (htmlContent, isGM = true) => +{ + const css = getCSS(); + const s = getState(isGM); + if(!s.selectedHandout) return ""; + + const effectiveLevel = s.selectedHeaderLevel === 0 + ? getHighestHeaderLevel((htmlContent || "") + (isGM ? (s._cachedGmNotes || "") : "")) + : s.selectedHeaderLevel; + + const buildButtons = (html, field, isGMField) => + { + if(!html) return { buttons: "", headers: [] }; + + const headerRegex = /<(h[1-4])\b[^>]*>([\s\S]*?)<\/\1>/gi; + let match; + let buttons = ""; + const orderedHeaders = []; + + while((match = headerRegex.exec(html)) !== null) + { + const level = parseInt(match[1][1], 10); + const text = match[2].replace(/<[^>]+>/g, '').trim(); + if(!text) continue; + + if(s.showBelow ? level < effectiveLevel : level !== effectiveLevel) continue; + + const start = match.index; + const remainder = html.slice(headerRegex.lastIndex); + const stopRegex = new RegExp(` sectionH5.includes(x)); + const passH6 = !f.h6.length || f.h6.some(x => sectionH6.includes(x)); + if(!passH5 || !passH6) continue; + + orderedHeaders.push({ text, field }); + + const encoded = encodeURIComponent(text); + const fieldArg = isGMField ? ` --field gmnotes` : ''; + const isActive = s.currentItemType === "header" && s.currentItemId === text && s.mode === "handout"; + const activeStyle = isActive ? " outline:2px solid #ddd; outline-offset:1px;" : ""; + + let levelStyle = ""; + switch(level) + { + case 1: levelStyle = isGMField ? css.h1ButtonGM : css.h1Button; break; + case 2: levelStyle = isGMField ? css.h2ButtonGM : css.h2Button; break; + case 3: levelStyle = isGMField ? css.h3ButtonGM : css.h3Button; break; + case 4: levelStyle = isGMField ? css.h4ButtonGM : css.h4Button; break; + } + + buttons += makeButton(text, `!wiki --show ${encoded}${fieldArg}`, css.listButtonBase + levelStyle + activeStyle); + } + + return { buttons, headers: orderedHeaders }; + }; + + const notesResult = buildButtons(htmlContent, 'notes', false); + let allButtons = notesResult.buttons; + let allHeaders = notesResult.headers; + + if(isGM && s.selectedHandout) + { + const handout = getObj('handout', s.selectedHandout); + if(handout) + { + const gmContent = s._cachedGmNotes || ""; + const gmResult = buildButtons(gmContent, 'gmnotes', true); + allButtons += gmResult.buttons; + allHeaders = allHeaders.concat(gmResult.headers); + } + } + + s.currentList = allHeaders; + return allButtons; +}; + + const buildLeftPanel = async (htmlContent, playerid, isGM = true) => + { + const s = getState(isGM); + let html = buildModeRow(isGM); + + if(s.mode === "handout") + { + html += buildHandoutSelector(isGM); + html += buildHeaderRow(isGM); + html += `
    `; + html += buildHeaderList(htmlContent, isGM); + html += `
    `; + } + else if(s.mode === "pins") + { + html += `
    `; + html += await buildPinList(playerid, isGM); + html += `
    `; + } + + return html; + }; + + const buildHeaderHTML = (isGM = true) => + { + const css = getCSS(); + const title = isGM ? scriptName : "Wiki"; + let html = `
    ${title}${isGM ? helpButton : ""}${homeButton}
    `; + html += buildKeywordRow('h5', isGM); + html += buildKeywordRow('h6', isGM); + return html; + }; + + const buildControlBar = (isGM = true) => + { + const s = getState(isGM); + const css = getCSS(); + + if(!s.currentItemType || !s.displayHTML) return ""; + + let buttons = ""; + + + + + if(s.navBack.length && s.mode !== "pins") + buttons += makeButton("◀ Back", "!wiki --nav-back", css.controlButton); + + if((s.navBack.length || s.navForward.length) && s.mode !== "pins") + buttons += makeButton("✕ History", "!wiki --nav-clear", css.controlButton); + + if(s.navForward.length && s.mode !== "pins") + buttons += makeButton("Forward ▶", "!wiki --nav-forward", css.controlButton); + + if(s.currentIndex >= 0) + { + buttons += makeButton("Previous", "!wiki --prev", css.controlButton); + buttons += makeButton("Next", "!wiki --next", css.controlButton); + } + + // Ping buttons for currently focused pin + if(s.mode === "pins" && s.currentItemType === "pin" && s.currentItemId) + { + const pin = getObj("pin", s.currentItemId); + if(pin) + { + if(isGM) + buttons += makeButton("@", `!wiki --ping-pin-gm ${s.currentItemId}`, css.pinPingButtonGM + "float:right; margin-left:10px;font-size:24px;"); + } buttons += makeButton("@", `!wiki --ping-pin ${s.currentItemId}`, css.pinPingButton + "float:right; margin-left:10px;font-size:24px;"); + } + + if(isGM) + { + if(s.currentItemType === "header") + { + const url = `http://journal.roll20.net/handout/${s.selectedHandout}`; + buttons += `Open`;//span necessary to override Roll20 default text color styling. + + } + else if(s.currentItemType === "pin") + { + const pin = getObj("pin", s.currentItemId); + + if(pin && pin.get("link")) + { + const url = `http://journal.roll20.net/handout/${pin.get("link")}`; + buttons += `Open`;//span necessary to override Roll20 default text color styling. + + } + else + { + buttons += makeButton("Edit", "!wiki --edit", css.controlButton); + } + } + + if(s.mode === "pins" && ( + (typeof pintool !== 'undefined') || + (typeof API_Meta !== 'undefined' && API_Meta.PinTool) +)) + {buttons += makeButton("Pintool", "!wiki --pintool", css.controlButton)}; + + /* + if(s.mode !== "pins"){ + let handoutForDisplay = getObj("handout", s.selectedHandout); + buttons += `
    ${handoutForDisplay.get("name")}
    `; + } + */ + + buttons += `Send to Chat: `; + buttons += makeButton("GM", "!wiki --send-chat", css.controlButton); + buttons += makeButton("Players", "!wiki --send-chat-players", css.controlButton); + //buttons += makeButton("Send to Chat", "!wiki --send-chat", css.controlButton); + } + + return `
    ${buttons}
    `; + }; + +const buildViewportHTML = async (htmlContent, playerid, isGM = true) => +{ + const css = getCSS(); + const s = getState(isGM); + const leftHTML = await buildLeftPanel(htmlContent, playerid, isGM); + const headerHTML = buildHeaderHTML(isGM); + const controlBar = buildControlBar(isGM); + + const bgURL = getWikiBackgroundURL(); + const containerStyle = bgURL + ? css.container + `background-image:url(${bgURL}); background-repeat:repeat;` + : css.container; + + let html = `
    `; + html += headerHTML; + html += ``; + html += ``; + html += ``; + + return html; +}; + /* ============================================================ + * VIEWPORT HANDOUT UPDATE + * ============================================================ */ + + const updateViewportHandout = async (htmlContent = "", playerid, isGM = true) => + { + try + { + const targetName = isGM ? VIEWPORT_HANDOUT_NAME : WIKI_HANDOUT_NAME; + let handout = findObjs({ _type: 'handout', name: targetName })[0]; + + if(!handout) + { + handout = createObj('handout', { name: targetName }); + if(!handout) + { + log(`!wiki ERROR: Could not create handout "${targetName}"`); + return; + } + handout.set('tags', JSON.stringify(['wiki-'])); + if(!isGM) handout.set('inplayerjournals', 'all'); + } + + let viewportHTML = ""; + try + { + viewportHTML = await buildViewportHTML(htmlContent, playerid, isGM); + } + catch(err) + { + log(`!wiki ERROR: buildViewportHTML threw: ${err}`); + viewportHTML = htmlContent; + } + + if(handout && handout.id) + handout.set({ notes: viewportHTML }); + else + log(`!wiki ERROR: handout is invalid or missing id`); + } + catch(e) + { + log(`!wiki ERROR: updateViewportHandout unexpected error: ${e}`); + } + }; + + /* ============================================================ + * ON READY + * ============================================================ */ + + on('ready', async () => + { + initializeState(); + + // Invalidate handout selector cache on any handout change + on('change:handout', () => + { + state.Wiki.gm._handoutSelectorCache = null; + state.Wiki.player._handoutSelectorCache = null; + }); + + await new Promise(resolve => setTimeout(resolve, 2000)); + + const homeHandout = getOrCreateWikiHome(); + if(!homeHandout) return; + + const gmState = state.Wiki.gm; + gmState.mode = "handout"; + + try + { + const content = await new Promise(resolve => + { + homeHandout.get('notes', async notes => + { + await withTimeout(new Promise(resolveGM => + { + homeHandout.get('gmnotes', gmContent => + { + gmState._cachedGmNotes = (gmContent && gmContent !== 'undefined' && gmContent !== 'null') + ? gmContent : ""; + resolveGM(); + }); + }), 1000, 'cachedGmNotes ready').catch(() => { gmState._cachedGmNotes = ""; }); + + resolve(notes); + }); + }); + + gmState.selectedHandout = homeHandout.id; + gmState.selectedHeaderLevel = 0; + gmState.currentList = []; + gmState.currentIndex = -1; + gmState.currentItemType = "header"; + gmState.currentItemId = '__all__'; + gmState.navBack = []; + gmState.navForward = []; + gmState.displayHTML = rewriteHandoutLinks( + getHandoutAvatarHTML(homeHandout.id) + + (await getHandoutSectionHTML(homeHandout.id, '__all__', 'notes', true) || "") + ); + + await updateViewportHandout(content, null, true); + log('Wiki: initialised to home page'); + } + catch(e) + { + log(`Wiki: on('ready') error: ${e}`); + } + }); + + +const getWikiBackgroundStyle = () => +{ + const bgURL = getWikiBackgroundURL(); + return bgURL + ? `background-image:url(${bgURL}); background-repeat:repeat;` + : "background-color:#ccc;"; +}; + + /* ============================================================ + * CHAT HANDLER + * ============================================================ */ + + on('chat:message', async msg => + { + if(msg.type !== 'api' || !msg.content.startsWith('!wiki')) + return; + + initializeState(); + const isGM = playerIsGM(msg.playerid); + const s = isGM ? state.Wiki.gm : state.Wiki.player; + const args = parseArgs(msg.content); + + if(args.help) + { + if(isGM) handleWikiHelp(msg); + return; + } + + s.currentPage = getPageForPlayer(msg.playerid); + + // ===================================================== + // PLAIN !wiki → ALWAYS GO TO WIKI HOME + // ===================================================== + if(!msg.content.includes('--')) + { + const homeHandout = getOrCreateWikiHome(); + if(homeHandout) args.selectHandout = homeHandout.id; + + const targetName = isGM ? VIEWPORT_HANDOUT_NAME : WIKI_HANDOUT_NAME; + const outputHandout = findObjs({ _type: 'handout', name: targetName })[0]; + + if(outputHandout) + { + const openURL = `http://journal.roll20.net/handout/${outputHandout.id}`; + + if(isGM) + { + sendStyledMessage(targetName, `[Open the GM Wiki](${openURL})`); + } + else + { + const css = getCSS(); + sendChat( + scriptName, + `/w "${msg.who}" `, + null, { noarchive: true } + ); + } + } + // Fall through to --selectHandout block + } + + // --- home button --- + if(args.home) + { + const homeHandout = getOrCreateWikiHome(); + if(homeHandout) args.selectHandout = homeHandout.id; + } + + // --- mode switch --- + if(args.mode) + { + s.mode = args.mode; + + if(s.mode === "pins") + { + await buildPinKeywords(msg.playerid, isGM); + + const lastPinId = s._lastPinId; + if(lastPinId) + { + const pin = getObj('pin', lastPinId); + if(pin) + { + let content = ""; + + if(pin.get('link')) + { + const handoutId = pin.get('link'); + const subHeader = pin.get('subLink'); + const field = pin.get('subLinkType') === 'headerGM' ? 'gmnotes' : 'notes'; + const resolvedField = (!isGM && field === 'gmnotes') ? 'notes' : field; + + content = await getHandoutSectionHTML(handoutId, subHeader, resolvedField, isGM) + || "(empty linked handout)"; + + if(pin.get('autoNotesType') === 'blockquote') + { + if(isGM) + { + content = content.replace(/(<\/blockquote>)([\s\S]+)/i, '$1
    $2'); + } + else + { + const blockquoteEnd = content.search(/<\/blockquote>/i); + content = blockquoteEnd !== -1 + ? content.slice(0, blockquoteEnd + ''.length) + : "(no player-visible content)"; + } + } + else if(!isGM) + { + content = "(no player-visible content)"; + } + } + else + { + const notes = pin.get('notes') || ""; + const gmNotes = pin.get('gmNotes') || ""; + const safeNotes = notes === "undefined" ? "" : notes; + const safeGmNotes = gmNotes === "undefined" ? "" : gmNotes; + content = safeNotes + (safeGmNotes ? "
    " + safeGmNotes : ""); + if(!content.trim()) content = "(empty pin)"; + } + + s.currentItemType = "pin"; + s.currentItemId = pin.id; + s.currentIndex = s.currentList.indexOf(pin.id); + s.displayHTML = rewriteHandoutLinks(content); + } + } + + await updateViewportHandout("", msg.playerid, isGM); + return; + } + else if(s.mode === "handout" && s.selectedHandout) + { + const handout = getObj('handout', s.selectedHandout); + if(handout) + { + await new Promise(resolve => + { + handout.get('notes', async content => + { + await withTimeout(new Promise(resolveGM => + { + handout.get('gmnotes', gmContent => + { + s._cachedGmNotes = (gmContent && gmContent !== 'undefined' && gmContent !== 'null') + ? gmContent : ""; + resolveGM(); + }); + }), 1000, 'cachedGmNotes modeSwitch').catch(() => { s._cachedGmNotes = ""; }); + + if(s.currentItemId && s.currentItemId !== '__all__' && s.currentItemType === 'header') + { + const field = s._currentField || 'notes'; + const restored = await getHandoutSectionHTML(s.selectedHandout, s.currentItemId, field, isGM); + + if(restored) + { + s.displayHTML = rewriteHandoutLinks(restored); + } + else + { + s.currentItemId = '__all__'; + s.currentIndex = -1; + s.displayHTML = rewriteHandoutLinks( + getHandoutAvatarHTML(s.selectedHandout) + + (await getHandoutSectionHTML(s.selectedHandout, '__all__', 'notes', isGM) || "") + ); + } + } + else + { + s.currentItemType = "header"; + s.currentItemId = '__all__'; + s.currentIndex = -1; + s.displayHTML = rewriteHandoutLinks( + getHandoutAvatarHTML(s.selectedHandout) + + (await getHandoutSectionHTML(s.selectedHandout, '__all__', 'notes', isGM) || "") + ); + } + + await updateViewportHandout(content, msg.playerid, isGM); + resolve(); + }); + }); + } + return; + } + } + + // --- handout selection --- + if(args.selectHandout) + { + const homeHandout = getOrCreateWikiHome(); + const isHomeNavigation = homeHandout && args.selectHandout === homeHandout.id; + + if(args.selectHandout !== s.selectedHandout && !isHomeNavigation) + pushNavState(args.selectHandout, isGM); + + if(isHomeNavigation) + { + s.navBack = []; + s.navForward = []; + } + + s.selectedHandout = args.selectHandout; + const handout = getObj('handout', s.selectedHandout); + + if(handout) + { + await new Promise(resolve => + { + handout.get('notes', async content => + { + s.selectedHeaderLevel = 0; + s.keywordData.handout.h5 = extractKeywords(content, 5); + s.keywordData.handout.h6 = extractKeywords(content, 6); + s.filters.handout.h5 = []; + s.filters.handout.h6 = []; + + await withTimeout(new Promise(resolveGM => + { + handout.get('gmnotes', gmContent => + { + s._cachedGmNotes = (gmContent && gmContent !== 'undefined' && gmContent !== 'null') + ? gmContent : ""; + resolveGM(); + }); + }), 1000, 'cachedGmNotes selectHandout').catch(() => { s._cachedGmNotes = ""; }); + + if(!args.show) + { + s.currentList = []; + s.currentIndex = -1; + s.currentItemType = "header"; + s.currentItemId = '__all__'; + s.displayHTML = rewriteHandoutLinks( + getHandoutAvatarHTML(s.selectedHandout) + + (await getHandoutSectionHTML(s.selectedHandout, '__all__', 'notes', isGM) || "") + ); + + await updateViewportHandout(content, msg.playerid, isGM); + } + + resolve(); + }); + }); + } + + if(!args.show) return; + } + + // --- header level --- +if(args.level) +{ + s.selectedHeaderLevel = parseInt(args.level, 10); + + if(s.selectedHandout) + { + const handout = getObj('handout', s.selectedHandout); + if(handout) + { + handout.get('notes', async content => + { + if(s.selectedHeaderLevel === 0) + { + s.currentList = []; + s.currentIndex = -1; + s.currentItemType = "header"; + s.currentItemId = '__all__'; + s.displayHTML = rewriteHandoutLinks( + getHandoutAvatarHTML(s.selectedHandout) + + (await getHandoutSectionHTML(s.selectedHandout, '__all__', 'notes', isGM) || "") + ); + } + await updateViewportHandout(content, msg.playerid, isGM); + }); + } + return; + } +} + + // --- below toggle --- + if(args.hasOwnProperty('below')) + { + s.showBelow = (args.below === "true"); + if(s.selectedHandout) + { + const handout = getObj('handout', s.selectedHandout); + if(handout) + { + await new Promise(resolve => + { + handout.get('notes', async content => + { + await updateViewportHandout(content, msg.playerid, isGM); + resolve(); + }); + }); + } + } + return; + } + + // --- previous --- + if(args.prev) + { + if(s.currentItemType === "header" && s.selectedHandout) + { + const handout = getObj('handout', s.selectedHandout); + if(handout) + { + const fullContent = await new Promise(resolve => handout.get('notes', resolve)); + s.currentList = getFilteredHeaders(fullContent, s.selectedHeaderLevel, s); + } + } + + if(s.currentIndex > 0) + { + s.currentIndex--; + const target = s.currentList[s.currentIndex]; + const targetText = target.text || target; + const targetField = target.field || 'notes'; + + if(s.currentItemType === "header") + { + args.show = encodeURIComponent(targetText); + args.field = targetField; + } + else + { + args['show-pin'] = target; + } + } + } + + // --- next --- + if(args.next) + { + if(s.currentItemType === "header" && s.selectedHandout) + { + const handout = getObj('handout', s.selectedHandout); + if(handout) + { + const fullContent = await new Promise(resolve => handout.get('notes', resolve)); + s.currentList = getFilteredHeaders(fullContent, s.selectedHeaderLevel, s); + } + } + + if(s.currentIndex < s.currentList.length - 1) + { + s.currentIndex++; + const target = s.currentList[s.currentIndex]; + const targetText = target.text || target; + const targetField = target.field || 'notes'; + + if(s.currentItemType === "header") + { + args.show = encodeURIComponent(targetText); + args.field = targetField; + } + else + { + args['show-pin'] = target; + } + } + } + + // --- show a header --- + if(args.show && s.selectedHandout) + { + const headerText = decodeURIComponent(args.show); + const field = args.field === 'gmnotes' ? 'gmnotes' : 'notes'; + const handout = getObj('handout', s.selectedHandout); + + if(handout) + { + const fullContent = await new Promise(resolve => handout.get('notes', resolve)); + + const crossHandout = args.selectHandout && args.selectHandout !== s.selectedHandout; + if(!crossHandout && s.currentItemId !== headerText) + pushNavState(s.selectedHandout, isGM); + + s.currentList = getFilteredHeaders(fullContent, s.selectedHeaderLevel, s); + s.currentIndex = s.currentList.findIndex(item => (item.text || item) === headerText); + s.currentItemType = "header"; + s.currentItemId = headerText; + s.displayHTML = rewriteHandoutLinks( + await getHandoutSectionHTML(s.selectedHandout, headerText, field, isGM) + || "Section not found." + ); + + await updateViewportHandout(fullContent, msg.playerid, isGM); + } + + return; + } + + // --- clear history --- + if(args['nav-clear']) + { + s.navBack = []; + s.navForward = []; + if(isGM) + sendStyledMessage("Navigation history cleared."); + else + sendChat(scriptName, `/w "${msg.who}" Navigation history cleared.`, null, { noarchive: true }); + return; + } + + // --- show a pin --- + if(args['show-pin']) + { + const pin = getObj('pin', args['show-pin']); + if(pin) + { + let content = ""; + + if(pin.get('link')) + { + const handoutId = pin.get('link'); + const subHeader = pin.get('subLink'); + const field = pin.get('subLinkType') === 'headerGM' ? 'gmnotes' : 'notes'; + const resolvedField = (!isGM && field === 'gmnotes') ? 'notes' : field; + + content = await getHandoutSectionHTML(handoutId, subHeader, resolvedField, isGM) + || "(empty linked handout)"; + + if(pin.get('autoNotesType') === 'blockquote') + { + if(isGM) + { + content = content.replace(/(<\/blockquote>)([\s\S]+)/i, '$1
    $2'); + } + else + { + const blockquoteEnd = content.search(/<\/blockquote>/i); + content = blockquoteEnd !== -1 + ? content.slice(0, blockquoteEnd + ''.length) + : "(no player-visible content)"; + } + } + else if(!isGM) + { + content = "(no player-visible content)"; + } + } + + s.displayHTML = rewriteHandoutLinks(content); + s.currentItemType = "pin"; + s.currentItemId = pin.id; + s._lastPinId = pin.id; + s.currentIndex = s.currentList.indexOf(pin.id); + + await updateViewportHandout("", msg.playerid, isGM); + } + + return; + } + + // --- audit pins --- + if(args['audit-pins']) + { + if(!isGM) return; + + const pageid = getPageForPlayer(msg.playerid); + if(!pageid) + { + sendStyledMessage("Audit Pins", "No current page found."); + return; + } + + const pins = findObjs({ _type: 'pin', _pageid: pageid }); + const issues = []; + + for(const p of pins) + { + const title = p.get('title') || p.get('subLink') || p.id; + const visibleTo = p.get('visibleTo'); + const link = p.get('link'); + const autoNotes = p.get('autoNotesType'); + + if(visibleTo === 'all' && link && autoNotes !== 'blockquote') + issues.push({ id: p.id, title }); + } + + if(!issues.length) + { + sendStyledMessage("Audit Pins", "No issues found on this page. All player-visible linked pins have autoNotesType set correctly."); + return; + } + + const css = getCSS(); + let body = `${issues.length} issue(s) found on this page:

    `; + + issues.forEach(issue => + { + body += `${makeButton("Fix", `!wiki --fix-pin ${issue.id}`, css.messageButton)} ${issue.title} — autoNotesType not set to "blockquote"
    `; + }); + + const allIds = issues.map(i => i.id).join(','); + body += `
    ${makeButton("Fix All", `!wiki --fix-pin-all ${allIds}`, css.messageButton)} Fix all ${issues.length} pins on this page`; + + sendStyledMessage("Audit Pins", body); + return; + } + + // --- fix pin --- + if(args['fix-pin']) + { + if(!isGM) return; + const pin = getObj('pin', args['fix-pin']); + if(pin) + { + pin.set('autoNotesType', 'blockquote'); + sendStyledMessage("Audit Pins", `Fixed: ${pin.get('title') || pin.get('subLink') || pin.id}`); + } + return; + } + + // --- fix pin all --- + if(args['fix-pin-all']) + { + if(!isGM) return; + const ids = args['fix-pin-all'].split(','); + let fixed = 0; + ids.forEach(id => + { + const pin = getObj('pin', id.trim()); + if(pin) { pin.set('autoNotesType', 'blockquote'); fixed++; } + }); + sendStyledMessage("Audit Pins", `Fixed ${fixed} pin(s) on this page.`); + return; + } + + // --- ping a pin --- + if(args['ping-pin']) + { + const pin = getObj('pin', args['ping-pin']); + if(pin) sendPing(pin.get('x'), pin.get('y'), pin.get('_pageid'), msg.playerid, true); + return; + } + + // --- ping a pin gm --- + if(args['ping-pin-gm']) + { + const pin = getObj('pin', args['ping-pin-gm']); + if(pin) sendPing(pin.get('x'), pin.get('y'), pin.get('_pageid'), "", true, msg.playerid); + return; + } + + // --- edit --- + if(args.edit) + { + if(s.currentItemType === "header") + { + sendChat("", `/w gm Open Handout`); + } + else if(s.currentItemType === "pin") + { + const pin = getObj("pin", s.currentItemId); + if(pin && pin.get("linkType") === "handout") + sendChat("", `/w gm Open Handout`); + } + return; + } + + // --- send to chat --- +if(args['send-chat'] && s.displayHTML) +{ + const bgStyle = getWikiBackgroundStyle(); + sendChat("", `/w gm
    ${s.displayHTML}
    `); + return; +} + +if(args['send-chat-players'] && s.selectedHandout) +{ + if(!isGM) return; + + // Build player-safe content using the same logic as the player wiki + let playerContent = ""; + + if(s.currentItemType === "pin" && s.currentItemId) + { + const pin = getObj('pin', s.currentItemId); + if(pin && pin.get('link')) + { + const handoutId = pin.get('link'); + const subHeader = pin.get('subLink'); + const field = pin.get('subLinkType') === 'headerGM' ? 'gmnotes' : 'notes'; + const resolvedField = field === 'gmnotes' ? 'notes' : field; + + playerContent = await getHandoutSectionHTML(handoutId, subHeader, resolvedField, false) + || "(no player-visible content)"; + + if(pin.get('autoNotesType') === 'blockquote') + { + const blockquoteEnd = playerContent.search(/<\/blockquote>/i); + if(blockquoteEnd !== -1) + playerContent = playerContent.slice(0, blockquoteEnd + ''.length); + else + playerContent = "(no player-visible content)"; + } + } + } + else if(s.currentItemType === "header" && s.selectedHandout) + { + const headerText = s.currentItemId === '__all__' ? null : s.currentItemId; + playerContent = await getHandoutSectionHTML(s.selectedHandout, headerText || '__all__', 'notes', false) + || "(no player-visible content)"; + } + + if(!playerContent) + { + sendStyledMessage("Send to Players", "No player-visible content to send."); + return; + } + + const bgStyle = getWikiBackgroundStyle(); + sendChat( + scriptName, + `
    ${rewriteHandoutLinks(playerContent)}
    ` + ); + return; +} + + // --- pintool --- + if(args.pintool) + { + sendChat("", "!pintool"); + return; + } + + // --- filter keywords (toggle) --- + let filtersChanged = false; + const mode = s.mode === "pins" ? "pins" : "handout"; + + ['h5', 'h6'].forEach(level => + { + if(args[`filter-${level}`]) + { + const word = normalizeWord(args[`filter-${level}`]); + const idx = s.filters[mode][level].indexOf(word); + if(idx === -1) s.filters[mode][level].push(word); + else s.filters[mode][level].splice(idx, 1); + filtersChanged = true; + } + + if(args[`clear-${level}`]) + { + s.filters[mode][level] = []; + filtersChanged = true; + } + }); + + if(filtersChanged && s.selectedHandout) + { + const handout = getObj('handout', s.selectedHandout); + if(handout) + { + const content = await new Promise(resolve => handout.get('notes', resolve)); + s.displayHTML = ""; + await updateViewportHandout(content, msg.playerid, isGM); + } + return; + } + + // --- nav back --- + if(args['nav-back'] && s.navBack.length) + { + s.navForward.push({ handoutId: s.selectedHandout, headerText: s.currentItemId || null }); + + const target = s.navBack.pop(); + s.selectedHandout = target.handoutId; + + const handout = getObj('handout', s.selectedHandout); + if(handout) + { + await new Promise(resolve => + { + handout.get('notes', async content => + { + s.keywordData.handout.h5 = extractKeywords(content, 5); + s.keywordData.handout.h6 = extractKeywords(content, 6); + + let restoredLevel = 1; + if(target.headerText) + { + for(let i = 1; i <= 4; i++) + { + if(extractHeaders(content, i).includes(target.headerText)) + { + restoredLevel = i; + break; + } + } + } + else + { + restoredLevel = getHighestHeaderLevel(content); + } + + s.selectedHeaderLevel = restoredLevel; + s.currentList = getFilteredHeaders(content, s.selectedHeaderLevel, s); + s.currentIndex = target.headerText ? s.currentList.indexOf(target.headerText) : 0; + s.currentItemType = "header"; + s.currentItemId = target.headerText || s.currentList[0] || null; + s.displayHTML = rewriteHandoutLinks( + await getHandoutSectionHTML(s.selectedHandout, s.currentItemId, 'notes', isGM) || "" + ); + + await updateViewportHandout(content, msg.playerid, isGM); + resolve(); + }); + }); + } + return; + } + + // --- nav forward --- + if(args['nav-forward'] && s.navForward.length) + { + s.navBack.push({ handoutId: s.selectedHandout, headerText: s.currentItemId || null }); + + const target = s.navForward.pop(); + s.selectedHandout = target.handoutId; + + const handout = getObj('handout', s.selectedHandout); + if(handout) + { + await new Promise(resolve => + { + handout.get('notes', async content => + { + s.keywordData.handout.h5 = extractKeywords(content, 5); + s.keywordData.handout.h6 = extractKeywords(content, 6); + + let restoredLevel = 1; + if(target.headerText) + { + for(let i = 1; i <= 4; i++) + { + if(extractHeaders(content, i).includes(target.headerText)) + { + restoredLevel = i; + break; + } + } + } + else + { + restoredLevel = getHighestHeaderLevel(content); + } + + s.selectedHeaderLevel = restoredLevel; + s.currentList = getFilteredHeaders(content, s.selectedHeaderLevel, s); + s.currentIndex = target.headerText ? s.currentList.indexOf(target.headerText) : 0; + s.currentItemType = "header"; + s.currentItemId = target.headerText || s.currentList[0] || null; + s.displayHTML = rewriteHandoutLinks( + await getHandoutSectionHTML(s.selectedHandout, s.currentItemId, 'notes', isGM) || "" + ); + + await updateViewportHandout(content, msg.playerid, isGM); + resolve(); + }); + }); + } + return; + } + + // --- default update (only when no other arg was handled) --- + if(s.selectedHandout && + !args.selectHandout && !args.show && !args.home && + !args.level && !args.mode && !args.below && + !args['nav-back'] && !args['nav-forward'] && !args['nav-clear'] && + !args.prev && !args.next && !args['show-pin'] && + !args.edit && !args['send-chat'] && !args.pintool && + !args['audit-pins'] && !args['fix-pin'] && !args['fix-pin-all'] && + !args['ping-pin'] && !args['ping-pin-gm'] && !args['filter-h5'] && + !args['filter-h6'] && !args['clear-h5'] && !args['clear-h6']) + { + const handout = getObj('handout', s.selectedHandout); + if(handout) + { + handout.get('notes', async content => + { + await updateViewportHandout(content, msg.playerid, isGM); + }); + return; + } + } + + await updateViewportHandout("", msg.playerid, isGM); + }); + +})(); + diff --git a/Wiki/Wiki.js b/Wiki/Wiki.js new file mode 100644 index 000000000..d7e70349d --- /dev/null +++ b/Wiki/Wiki.js @@ -0,0 +1,2239 @@ +// Script: Wiki +// By: Keith Curtis +// Contact: https://app.roll20.net/users/162065/keithcurtis + +const wiki = (() => { + 'use strict'; + const version = '1.0.0'; //version number set here + log('-=> Wiki v' + version + ' is loaded. Use "!wiki" to start.'); + //1.0.0 Debut + + const scriptName = "Wiki"; + const VIEWPORT_HANDOUT_NAME = "Wiki - GM"; + const WIKI_HANDOUT_NAME = "Wiki - Player"; + + /* ============================================================ + * STATE + * ============================================================ */ + + const STATE_DEFAULTS = () => ( + { + mode: "handout", + selectedHandout: null, + selectedHeaderLevel: 1, + showBelow: false, + currentPage: null, + navBack: [], + navForward: [], + filters: + { + handout: { h5: [], h6: [] }, + pins: { h5: [], h6: [] } + }, + keywordData: + { + handout: { h5: [], h6: [] }, + pins: { h5: [], h6: [] } + }, + currentItemType: null, + currentItemId: null, + currentList: [], + currentIndex: -1, + _lastPinId: null, + _handoutSelectorCache: null, + displayHTML: "" + }); + + const initializeState = () => + { + state.Wiki = state.Wiki || {}; + state.Wiki.gm = state.Wiki.gm || STATE_DEFAULTS(); + state.Wiki.player = state.Wiki.player || STATE_DEFAULTS(); + + const ensurePartition = (p) => + { + p.filters = p.filters || {}; + p.filters.handout = p.filters.handout || { h5:[], h6:[] }; + p.filters.pins = p.filters.pins || { h5:[], h6:[] }; + p.keywordData = p.keywordData || {}; + p.keywordData.handout = p.keywordData.handout || { h5:[], h6:[] }; + p.keywordData.pins = p.keywordData.pins || { h5:[], h6:[] }; + if(!Array.isArray(p.navBack)) p.navBack = []; + if(!Array.isArray(p.navForward)) p.navForward = []; + if(!Array.isArray(p.currentList)) p.currentList = []; + if(typeof p.currentIndex !== "number") p.currentIndex = -1; + if(typeof p._lastPinId === 'undefined') p._lastPinId = null; + if(typeof p._handoutSelectorCache === 'undefined') p._handoutSelectorCache = null; + p.displayHTML = p.displayHTML || ""; + }; + + ensurePartition(state.Wiki.gm); + ensurePartition(state.Wiki.player); + }; + + const getState = (isGM = true) => isGM ? state.Wiki.gm : state.Wiki.player; + + /* ============================================================ + * CONSTANTS + * ============================================================ */ + + const WIKI_HOME_HANDOUT_NAME = "Wiki Home"; + const WIKI_HOME_DEFAULT_TEXT = ` +

    Welcome to the Wiki

    +

    This is your campaign Wiki home page. Edit this handout to customize it.

    +

    You can use this handout as a landing page for players, with links to other handouts, lore, and campaign information.

    +

    Getting Started

    +

    To navigate the Wiki:

    +
      +
    • Use the HANDOUTS button to switch to handout browsing mode.
    • +
    • Use the PINS button to browse map pins on the current page.
    • +
    • Select a handout from the left panel to read it.
    • +
    • Use the header level buttons (H1–H4) to filter by section depth.
    • +
    +`; + + /* ============================================================ + * CSS — module-level constant, never rebuilt + * ============================================================ */ + + const CSS = + { + container: "width:100%; min-height:600px;font-family:Arial, sans-serif;border:4px solid #422c26;", + header: "background:#422c26; color:#ddd; font-family: Nunito, Arial, sans-serif; font-weight:bold; text-align:left; font-size:20px; padding:4px;", + layoutTable: "width:100%; border-collapse:collapse; table-layout:fixed;", + leftPanel: "background:#422c26; width:220px; vertical-align:top; padding:4px; box-sizing:border-box;", + rightPanel: "vertical-align:top; padding:6px; box-sizing:border-box;", + + modeRow: "display:table; width:100%; border-collapse:separate; border-spacing:4px 0; margin-bottom:4px; padding:0;", + modeRowButton: "display:table-cell; width:1%; font-weight:bold; text-align:center; padding:6px 8px; border:none; border-radius:4px; background:#6B3728; text-decoration:none; color:#ddd; font-size:12px; box-sizing:border-box; cursor:pointer;", + + headerRow: "display:table; width:100%; margin-bottom:4px;", + headerRowButton: "display:table-cell; width:1%; text-align:center; padding:4px; border:1px solid #444; background:#cfb080; text-decoration:none; color:#222; font-size:12px; box-sizing:border-box;", + + handoutButton: "display:block; width:calc(100% - 15px); margin:4px 0; padding:6px; border:1px solid #444; border-radius:4px; background:#cfb080; text-decoration:none; color:black; font-size:12px; box-sizing:border-box;", + listButton: "display:block; width:100%; margin:0; padding:4px; border:1px solid #444; border-radius:4px; background:#cfb080; text-decoration:none; color:black; font-size:12px; box-sizing:border-box;", + listButtonBase: "display:block; width:180px; margin:2px 0; padding:4px; border-radius:4px; text-decoration:none; color:#222; font-size:12px; box-sizing:border-box;", + + pinRowTable: "width:100%; border-collapse:collapse; margin:0px 0;border-style:none;", + pinMainCell: "width:100px; padding:0 2px 0 0;border-style:none;", + pinPingCell: "width:28px; height:15px; text-align:center; padding:0; font-family:pictos;border-style:none;", + + pinPingButton: "display:inline-block; width:10px; background:transparent; border:none; font-family:pictos; font-size:16px; text-decoration:none; color:#cfb080; cursor:pointer;", + pinPingButtonGM: "display:inline-block; width:10px; background:transparent; border:none; font-family:pictos; font-size:16px; text-decoration:none; color:#ddd; cursor:pointer;", + + h1Button: "background:#a47148; border:1px solid #8c5e3b; font-weight:bolder; margin-left:0px;", + h2Button: "background:#c28b5a; border:1px solid #a47148; font-weight:bold; margin-left:5px;", + h3Button: "background:#d9a873; border:1px solid #c28b5a; font-weight:normal; margin-left:10px;", + h4Button: "background:#f0c98f; border:1px solid #d9a873; font-weight:lighter; margin-left:15px;", + + h1ButtonGM: "background:#666; border:1px solid #666; font-weight:bolder; margin-left:0px;", + h2ButtonGM: "background:#777; border:1px solid #777; font-weight:bold; margin-left:5px;", + h3ButtonGM: "background:#888; border:1px solid #888; font-weight:normal; margin-left:10px;", + h4ButtonGM: "background:#999; border:1px solid #999; font-weight:lighter; margin-left:15px;", + + keywordRow: "text-align:left; margin:2px 0;color:#eee; font-weight:bold; background-color:#422c26;", + keywordButton: "display:inline-block; padding:1px 2px; margin:1px; border-radius:4px; border-style:none; background:#f0c98f; color:#111; font-size:10px; cursor:pointer;", + + controlBar: "background:#2e1f1a; padding:6px; border-bottom:1px solid #111; text-align:center;", + controlButton: "display:inline-block; margin:0 3px; padding:4px 8px; background:#6B3728; color:#ddd !important; border-radius:4px; text-decoration:none; font-size:12px;font-weight:bold;", + + messageContainer: 'background-color:#222; color:#ccc; Border: solid 1px #444; border-radius:5px; padding:10px; position:relative; top:-15px; left:-5px; font-family: Nunito, Arial, sans-serif;', + messageTitle: 'color:#ddd; margin-bottom:13px; font-size:16px; text-transform: capitalize; text-align:center;', + messageButton: 'background:#444; color:#ccc; border-radius:4px; padding:2px 6px; margin-right:2px; display:inline-block; vertical-align:middle' + }; + + const getCSS = () => CSS; + + /* ============================================================ + * UTILITIES + * ============================================================ */ + + const getPageForPlayer = (playerid) => + { + if(!playerid) return Campaign().get('playerpageid'); + const player = getObj('player', playerid); + if(!player) return Campaign().get('playerpageid'); + + if(playerIsGM(playerid)) + return player.get('lastpage') || Campaign().get('playerpageid'); + + const psp = Campaign().get('playerspecificpages'); + if(psp && psp[playerid]) return psp[playerid]; + + return Campaign().get('playerpageid'); + }; + + const withTimeout = (promise, ms = 5000, label = '') => + Promise.race([ + promise, + new Promise((_, reject) => + setTimeout(() => reject(new Error(`Timeout: ${label}`)), ms) + ) + ]); + + const sendStyledMessage = (titleOrMessage, messageOrUndefined, isPublic = false) => + { + const css = getCSS(); + let title, message; + + if(messageOrUndefined === undefined) + { + title = scriptName; + message = titleOrMessage; + } + else + { + title = titleOrMessage || scriptName; + message = messageOrUndefined; + } + + message = String(message).replace( + /\[([^\]]+)\]\(([^)]+)\)/g, + (_, label, command) => + `${label}` + ); + + const html = + `
    +
    ${title}
    + ${message} +
    `; + + sendChat( + scriptName, + `${isPublic ? "" : "/w gm "}${normalizeForChat(html)}`, + null, + { noarchive: true } + ); + }; + + const makeButton = (label, command, style) => `${label}`; + const normalizeForChat = (html) => html.replace(/\r?\n/g, ''); + + const helpButton = `?`; + const homeButton = ``; + + /* ============================================================ + * HELP HANDOUT + * ============================================================ */ + + const WIKI_HELP_NAME = "Help: Wiki"; + const WIKI_HELP_AVATAR = "https://files.d20.io/images/470559564/QxDbBYEhr6jLMSpm0x42lg/original.png?1767857147"; + +const WIKI_HELP_TEXT = ` +

    Wiki Interface Guide

    + +

    +The Wiki provides a unified interface for browsing campaign +information. Instead of opening multiple journal entries or searching through +pins on the map, the Wiki lets you navigate everything from one panel using +buttons and filters. +

    + +

    +The Wiki has two separate handouts: +

    + +
      +
    • Wiki - GM — the full GM interface, accessible only to the GM
    • +
    • Wiki - Player — the player-facing interface, shared with all players
    • +
    + +

    +Both are updated automatically when you interact with the Wiki. +The GM sees all content including GM Notes; players see only content from +handouts shared with them, and only the player-facing portion of pin descriptions. +

    + +
    + +

    Getting Started

    + +

    +Type !wiki in chat to open the Wiki. A link to the +Wiki - GM handout will be whispered to you. Players who type +!wiki receive a link to Wiki - Player. +

    + +

    +The interface is divided into two main areas: +

    + +
      +
    • Navigation Panel — the left panel, listing items you can open
    • +
    • Content Panel — the right panel, showing the currently viewed content
    • +
    + +
    + +

    Wiki Home

    + +

    +Typing !wiki always returns to the Wiki Home handout. +The home button () in the top-right corner of the +interface does the same. Navigating home clears the Back and Forward history. +

    + +

    +Wiki Home is a regular handout shared with all players. +Edit it freely to create a campaign landing page. Links to other handouts +inside Wiki Home will open directly in the Wiki panel when clicked. +

    + +

    Background Image

    + +

    +You can set a background image for the entire Wiki interface by adding an +image URL as a tag on the Wiki Home handout. The URL must begin with +https://. If multiple URL tags are present, the first one +is used. The background will tile across the interface container. +

    + +
    + +

    Handout Mode

    + +

    +Handout Mode lets you read handouts in the content panel. +Select a handout using the chooser button at the top of the navigation panel. +The currently active mode button is outlined. +

    + +

    +When a handout is selected, its full contents are displayed and all headers +appear in the navigation list. Click any header button to jump to that section. +The currently viewed section is outlined in the navigation list. +

    + +

    Header Level Buttons

    + +
      +
    • All — shows the entire handout and lists all headers at the highest available level
    • +
    • H1–H4 — filters the navigation list to show only headers at that level
    • +
    • { — when active, also shows headers below the selected level
    • +
    + +

    GM Notes Headers

    + +

    +For the GM, headers from the handout's GM Notes field also +appear in the navigation list, shown in grey to distinguish them from the +brown Notes headers. Clicking a grey header loads that GM Notes section into +the content panel. In the content panel, Notes and GM Notes content are +separated by a horizontal rule. +

    + +

    Handout Avatars

    + +

    +If a handout has an avatar image set, it is displayed at the top of the +content panel when viewing the full handout in All mode. +

    + +

    Handout Links

    + +

    +Links to other Roll20 handouts inside your content are automatically +rewritten so that clicking them opens the target handout directly in the +Wiki panel, rather than in a separate browser tab. +

    + +

    Using Keywords (H5–H6)

    + +

    +Keywords are optional tags used to filter sections. +Create them by adding H5 or H6 headers +inside a handout. +

    + +

    Example:

    + +
    +H2  Abandoned Mine
    +H5  dungeon
    +H6  goblins
    +
    + +

    +Clicking a keyword button filters the navigation list to show only sections +containing that keyword. Multiple keywords can be active at once. +Use Clear All to remove active filters. +

    + +
    + +

    Pin Mode

    + +

    +Pin Mode lists all map pins on the current page, sorted alphabetically. +The currently active mode button is outlined. The currently selected pin +is outlined in the navigation list. +

    + +

    +Selecting a pin loads its content into the content panel. +Switching back to Handout Mode and then returning to Pin Mode will +restore the last viewed pin automatically. +

    + +

    Where Pin Content Comes From

    + +
      +
    • Direct notes — text stored directly on the pin
    • +
    • A linked handout section — content pulled from a specific header in a handout, including GM Notes headers
    • +
    + +

    Player Visibility in Pin Mode

    + +

    +For pins linked to a handout, content is separated using a +blockquote. Players see only the content inside the +blockquote. Everything after it is GM-only. The GM sees all content +with a horizontal rule separating the two sections. +

    + +

    +Pins without a blockquote show no content to players. +

    + +

    +Use !wiki --audit-pins to scan the current page for +player-visible linked pins that are not correctly configured. Use the +Fix and Fix All buttons in the audit +report to correct them automatically. +

    + +

    Ping Buttons

    + +

    +Each pin entry in the navigation list includes two +@ buttons. +The same buttons also appear in the content panel header when a pin is selected. +

    + +
      +
    • Gold @ — pings the pin location for all players
    • +
    • Grey @ — pings the pin location for the GM only
    • +
    + +
    + +

    Content Panel Buttons

    + +

    +The control bar above the content panel provides navigation and action buttons. +

    + +
      +
    • ◀ Back — returns to the previously viewed handout or section (GM and players)
    • +
    • Forward ▶ — moves forward through history after going Back (GM and players)
    • +
    • ✕ History — clears all Back and Forward history (GM and players)
    • +
    • Previous / Next — steps through the filtered navigation list sequentially
    • +
    • Edit — opens the source handout for editing (GM only)
    • +
    • Send to Chat — sends the current content to GM chat. Does not filter GM-only content. (GM only)
    • +
    • Pintool — opens the Pintool interface if installed, in Pin Mode (GM only)
    • +
    + +

    +Back and Forward track navigation across different handouts and sections. +Previous and Next step through the current filtered list without affecting history. +GM and player Back/Forward histories are tracked independently. +

    + +
    + +

    Player Access

    + +

    +Players type !wiki in chat to receive a link to +Wiki - Player. The interface updates automatically when +they interact with it. Players have their own independent navigation history. +

    + +

    +Players can see handouts that have been shared with them via Roll20's +journal permissions. The GM can also grant access to any handout by +tagging it wiki+, regardless of journal permissions. +

    + +
    + +

    Handout Tags

    + +
      +
    • wiki+ — makes a handout visible to players in the Wiki chooser, regardless of journal permissions
    • +
    • wiki- — hides a handout from the Wiki chooser entirely. The Wiki - GM and Wiki - Player interface handouts are tagged this way automatically.
    • +
    + +

    +Tags are set in the handout's Edit mode using the Tags field. +

    + +
    + +

    Commands Reference

    + +
      +
    • !wiki — open the Wiki and go to Wiki Home
    • +
    • !wiki --help — open this help document
    • +
    • !wiki --audit-pins — scan current page for misconfigured player-visible pins
    • +
    + +
    + +

    Tips for Organizing Your Campaign

    + +
      +
    • Use H1–H4 in handouts for structure and navigation.
    • +
    • Use H5–H6 as keyword tags for filtering.
    • +
    • Keep keywords short and consistent across handouts.
    • +
    • Use blockquotes in linked pin sections to mark the player/GM boundary.
    • +
    • Set handout avatars to give locations and topics a visual identity.
    • +
    • Edit Wiki Home with links to your most-used handouts as a campaign dashboard.
    • +
    • Add an image URL as a tag on Wiki Home to set a background texture for the interface.
    • +
    • Use wiki+ to share specific handouts with players without changing journal permissions.
    • +
    • Use wiki- to hide utility or system handouts from the chooser.
    • +
    +`; + + function handleWikiHelp(msg) + { + if(msg.type !== "api") return; + + let handout = findObjs({ _type: "handout", name: WIKI_HELP_NAME })[0]; + + if(!handout) + { + handout = createObj("handout", + { + name: WIKI_HELP_NAME, + archived: false, + avatar: WIKI_HELP_AVATAR, + }); + } + + handout.set("avatar", WIKI_HELP_AVATAR); + handout.set("notes", WIKI_HELP_TEXT); + + const link = `http://journal.roll20.net/handout/${handout.get("_id")}`; + const box = ``; + + sendChat("Wiki", `/w gm ${box}`, null, { noarchive: true }); + } + + /* ============================================================ + * WIKI HOME + * ============================================================ */ + +const getWikiBackgroundURL = () => +{ + const homeHandout = findObjs({ _type: 'handout', name: WIKI_HOME_HANDOUT_NAME })[0]; + if(!homeHandout) return null; + + let tags = []; + try + { + const raw = homeHandout.get('tags'); + if(Array.isArray(raw)) tags = raw; + else if(typeof raw === 'string' && raw) tags = JSON.parse(raw); + } + catch(e) {} + + return tags.find(t => /^https?:\/\//i.test(t)) || null; +}; + + const getOrCreateWikiHome = () => + { + let handout = findObjs({ _type: 'handout', name: WIKI_HOME_HANDOUT_NAME })[0]; + + if(!handout) + { + handout = createObj('handout', + { + name: WIKI_HOME_HANDOUT_NAME, + inplayerjournals: 'all', + archived: false + }); + + if(handout) handout.set('notes', WIKI_HOME_DEFAULT_TEXT); + } + + return handout; + }; + + /* ============================================================ + * LINK REWRITING + * ============================================================ */ + + const rewriteHandoutLinks = (html) => + { + if(!html) return html; + + html = html.replace( + /href="http:\/\/journal\.roll20\.net\/handout\/([^"#/]+)\/?(?:#([^"]+))?"/g, + (match, handoutId, anchor) => + { + if(anchor) + { + const decoded = decodeURIComponent(anchor); + return `href="!wiki --selectHandout ${handoutId} --show ${encodeURIComponent(decoded)}"`; + } + return `href="!wiki --selectHandout ${handoutId}"`; + } + ); + + html = html.replace( + /href="(https:\/\/app\.roll20\.net\/compendium\/[^"]+)"/g, + (match, url) => + { + try { return `href="${decodeURIComponent(url)}"`; } + catch(e) { return `href="${url.replace(/%27/g,"'").replace(/%20/g," ").replace(/%28/g,"(").replace(/%29/g,")")}"` ; } + } + ); + + return html; + }; + + /* ============================================================ + * CORE ASYNC HELPERS + * ============================================================ */ + + const getHandoutAvatarHTML = (handoutId) => + { + const handout = getObj('handout', handoutId); + if(!handout) return ""; + + const avatar = handout.get('avatar'); + if(!avatar || avatar === '' || avatar === 'https://s3.amazonaws.com/files.d20.io/images/4277467/iKYSQhLKGRCLZuyBbZHbeA/thumb.jpg?1401938539') + return ""; + + return ``; + }; + + const getHandoutSectionHTML = (handoutId, headerText = null, field = 'notes', isGM = false) => + { + return withTimeout(new Promise(resolve => + { + const handout = getObj('handout', handoutId); + if(!handout) return resolve(null); + + handout.get(field, notes => + { + if(!notes) notes = ""; + + if(isGM && field !== 'gmnotes') + { + const assembleSection = (notesContent, gmNotesContent) => + { + if(!notesContent && !gmNotesContent) return null; + + let result = notesContent; + + if(/<\/blockquote>/i.test(result)) + { + result = result.replace( + /(<\/blockquote>)([\s\S]+)?/i, + (m, closing, after) => closing + (after ? `
    ${after}` : '') + ); + } + + const cleanGmNotes = (gmNotesContent || "") + .replace(/\r?\n/g, '') + .replace(/^null$/i, '') + .trim(); + + if(cleanGmNotes) result += `
    ${cleanGmNotes}`; + + return result.replace(/\r?\n/g, ''); + }; + + withTimeout(new Promise(resolveGM => + { + handout.get('gmnotes', gmNotes => + { + gmNotes = (gmNotes && gmNotes !== 'undefined') ? gmNotes : ""; + resolveGM(gmNotes); + }); + }), 1000, `gmnotes ${handoutId}`) + .then(gmNotes => + { + if(!headerText || headerText === '__all__') + return resolve(assembleSection(notes, gmNotes)); + + const section = extractSection(notes, headerText); + const gmSection = gmNotes ? extractSection(gmNotes, headerText) : ""; + + if(!section && !gmSection) return resolve(null); + + resolve(assembleSection(section || "", gmSection || "")); + }) + .catch(e => + { + log(`Wiki: gmnotes timeout for ${handoutId}: ${e}`); + + if(!headerText || headerText === '__all__') + return resolve(notes.replace(/\r?\n/g, '') || null); + + const section = extractSection(notes, headerText); + resolve(section ? section.replace(/\r?\n/g, '') : null); + }); + } + else + { + const processContent = (content) => + { + if(!content) return null; + + if(!isGM && /<\/blockquote>/i.test(content)) + { + const blockquoteEnd = content.search(/<\/blockquote>/i); + return content.slice(0, blockquoteEnd + ''.length) + .replace(/\r?\n/g, ''); + } + + return content.replace(/\r?\n/g, ''); + }; + + if(!headerText || headerText === '__all__') + return resolve(processContent(notes)); + + resolve(processContent(extractSection(notes, headerText))); + } + }); + }), 5000, `getHandoutSectionHTML ${handoutId}`) + .catch(e => + { + log(`Wiki: getHandoutSectionHTML timeout for ${handoutId}: ${e}`); + return null; + }); + }; + + /* ============================================================ + * NAVIGATION STATE + * ============================================================ */ + + const pushNavState = (destinationHandoutId, isGM = true) => + { + const s = getState(isGM); + + if(!s.currentItemType || !s.selectedHandout) return; + + const top = s.navBack[s.navBack.length - 1]; + if(top && top.handoutId === s.selectedHandout && top.headerText === s.currentItemId) return; + + s.navBack.push( + { + handoutId: s.selectedHandout, + headerText: s.currentItemId || null + }); + + s.navForward = []; + }; + + /* ============================================================ + * PARSING & EXTRACTION + * ============================================================ */ + + const normalizeWord = (word) => word.toLowerCase().replace(/[^\w]/g, ''); + + const parseArgs = (content) => + { + const args = {}; + const regex = /--([^\s]+)(?:\s+([^]*?))?(?=\s+--|$)/g; + let match; + + while((match = regex.exec(content)) !== null) + { + const key = match[1]; + let raw = (match[2] || "").trim(); + + if(raw.startsWith('"') && raw.endsWith('"')) raw = raw.slice(1, -1); + + args[key] = raw || true; + } + + return args; + }; + + const extractHeaders = (html, level) => + { + const regex = new RegExp(`]*>([\\s\\S]*?)<\\/h${level}>`, 'gi'); + const results = []; + let match; + + while((match = regex.exec(html)) !== null) + results.push(match[1].replace(/<[^>]+>/g, '').trim()); + + return results; + }; + + const getFilteredHeaders = (html, level, stateObj) => + { + let headers = extractHeaders(html, level); + const f = stateObj.filters.handout; + const levelKey = level === 5 ? 'h5' : 'h6'; + + if(!f[levelKey].length) return headers; + + return headers.filter(h => + f[levelKey].every(word => normalizeWord(h).includes(word)) + ); + }; + + const extractSection = (content, headerText) => + { + if(!content) return null; + + const headerRegex = /<(h[1-6])\b[^>]*>([\s\S]*?)<\/\1>/gi; + let match; + + while((match = headerRegex.exec(content)) !== null) + { + const level = parseInt(match[1][1], 10); + const stripped = match[2].replace(/<[^>]+>/g, '').trim(); + + if(stripped === headerText) + { + const start = match.index; + const remainder = content.slice(headerRegex.lastIndex); + const stopRegex = new RegExp(` + { + const headers = extractHeaders(html, level); + const words = new Set(); + + headers.forEach(text => + { + const cleaned = text + .replace(/ /gi, ' ') + .replace(/\u00A0/g, ' ') + .replace(/\s+/g, ' ') + .trim(); + + cleaned.split(' ').forEach(w => + { + const n = normalizeWord(w); + if(n) words.add(n); + }); + }); + + return Array.from(words).sort(); + }; + + const getHighestHeaderLevel = (html) => + { + for(let i = 1; i <= 4; i++) + { + if(new RegExp(` +{ + const s = getState(isGM); + const pageid = getPageForPlayer(playerid); + if(!pageid) return; + + const pins = findObjs({ _type: 'pin', _pageid: pageid }); + const h5 = new Set(); + const h6 = new Set(); + + const processedHandouts = new Set(); + + for(const p of pins) + { + if(!p.get('link')) continue; + + const handoutId = p.get('link'); + if(processedHandouts.has(handoutId)) continue; + processedHandouts.add(handoutId); + + const handout = getObj('handout', handoutId); + if(!handout) continue; + + const content = await withTimeout(new Promise(resolve => + { + handout.get('notes', notes => + { + resolve((notes && notes !== 'undefined') ? notes : ""); + }); + }), 3000, `buildPinKeywords notes ${handoutId}`).catch(() => ""); + + extractKeywords(content, 5).forEach(w => h5.add(w)); + extractKeywords(content, 6).forEach(w => h6.add(w)); + } + + s.keywordData.pins.h5 = Array.from(h5).sort(); + s.keywordData.pins.h6 = Array.from(h6).sort(); +}; + +const filterPins = async (pins, isGM = true) => +{ + const s = getState(isGM); + const result = []; + const contentCache = {}; + + for(const p of pins) + { + if(p.get('linkType') !== 'handout') + { + if(!s.filters.pins.h5.length && !s.filters.pins.h6.length) + result.push(p); + continue; + } + + const handoutId = p.get('link'); + const subHeader = p.get('subLink'); + const field = p.get('subLinkType') === 'headerGM' ? 'gmnotes' : 'notes'; + const f = s.filters.pins; + + if(!f.h5.length && !f.h6.length) + { + result.push(p); + continue; + } + + // Fetch the entire handout field to search within it + const cacheKey = `${handoutId}:${field}`; + if(!contentCache[cacheKey]) + { + const handout = getObj('handout', handoutId); + if(handout) + { + contentCache[cacheKey] = await withTimeout(new Promise(resolve => + { + handout.get(field, notes => + { + resolve((notes && notes !== 'undefined') ? notes : ""); + }); + }), 3000, `filterPins ${field} ${handoutId}`).catch(() => ""); + } + else + { + contentCache[cacheKey] = ""; + } + } + + // Extract just the pinned section from the full handout content + const fullContent = contentCache[cacheKey]; + const sectionContent = subHeader ? (extractSection(fullContent, subHeader) || "") : fullContent; + + const h5Words = extractKeywords(sectionContent, 5); + const h6Words = extractKeywords(sectionContent, 6); + + const passH5 = !f.h5.length || f.h5.some(x => h5Words.includes(x)); + const passH6 = !f.h6.length || f.h6.some(x => h6Words.includes(x)); + + if(passH5 && passH6) result.push(p); + } + + return result; +}; + /* ============================================================ + * PIN LIST BUILDER + * ============================================================ */ + +const buildPinList = async (playerid, isGM = true) => +{ + const s = getState(isGM); + const css = getCSS(); + const pageid = getPageForPlayer(playerid); + s.currentPage = pageid; + + if(!pageid) return "No pins found."; + + let pins = findObjs({ _type: 'pin', _pageid: pageid }) + .filter(p => isGM || p.get('visibleTo') === 'all'); + + if(!pins.length) return "No pins found."; + + pins = await filterPins(pins, isGM); + if(!pins.length) return "No pins found."; + + pins.sort((a, b) => + { + const titleA = (a.get('title') || a.get('subLink') || "(unnamed)") + .replace(/ /gi, ' ').replace(/\u00A0/g, ' ').trim().toLowerCase(); + const titleB = (b.get('title') || b.get('subLink') || "(unnamed)") + .replace(/ /gi, ' ').replace(/\u00A0/g, ' ').trim().toLowerCase(); + return titleA.localeCompare(titleB, undefined, { sensitivity: 'base' }); + }); + + let html = ""; + let orderedPins = []; + + for(const p of pins) + { + orderedPins.push(p.id); + + const title = p.get('title') || p.get('subLink') || "(unnamed)"; + const isActive = s.currentItemType === "pin" && s.currentItemId === p.id; + const activeStyle = isActive ? " outline:2px solid #ddd; outline-offset:1px;" : ""; + + const mainButton = makeButton(title, `!wiki --show-pin ${p.id}`, css.listButton + activeStyle); + const pingButton = makeButton("@", `!wiki --ping-pin ${p.id}`, css.pinPingButton); + const pingButtonGM = makeButton("@", `!wiki --ping-pin-gm ${p.id}`, css.pinPingButtonGM); + + html += `
    ${leftHTML}${controlBar}${s.displayHTML || ""}
    ${mainButton}${pingButton}${isGM ? pingButtonGM : ""}
    `; + } + + s.currentList = orderedPins; + return html; +}; + + /* ============================================================ + * UI BUILDERS + * ============================================================ */ + + const buildHandoutSelector = (isGM = true) => + { + const css = getCSS(); + const s = getState(isGM); + + if(!s._handoutSelectorCache) + { + s._handoutSelectorCache = findObjs({ _type: 'handout' }) + .filter(h => + { + let tags = []; + try + { + const raw = h.get('tags'); + if(Array.isArray(raw)) tags = raw; + else if(typeof raw === 'string' && raw) tags = JSON.parse(raw); + } + catch(e) {} + + if(tags.includes('wiki-')) return false; + return isGM || h.get('inplayerjournals') || tags.includes('wiki+'); + }) + .sort((a, b) => a.get('name').localeCompare(b.get('name'))) + .map(h => ({ name: h.get('name'), id: h.id })); + } + + const query = "?{Select Handout|" + + s._handoutSelectorCache.map(h => `${h.name},${h.id}`).join("|") + + "}"; + + const label = s.selectedHandout ? + getObj('handout', s.selectedHandout)?.get('name') : + "Choose Handout"; + + return makeButton(label, `!wiki --selectHandout ${query}`, css.handoutButton); + }; + +const buildModeRow = (isGM = true) => +{ + const css = getCSS(); + const s = getState(isGM); + const active = "outline:2px solid #ddd; outline-offset:1px;"; + + return `
    + ${makeButton("HANDOUTS", "!wiki --mode handout", css.modeRowButton + (s.mode === "handout" ? active : ""))} + ${makeButton("PINS", "!wiki --mode pins", css.modeRowButton + (s.mode === "pins" ? active : ""))} +
    `; +}; + + const buildHeaderRow = (isGM = true) => + { + const css = getCSS(); + const s = getState(isGM); + let buttons = makeButton(`All`, `!wiki --level 0`, css.headerRowButton); + + for(let i = 1; i <= 4; i++) + buttons += makeButton(`h${i}`, `!wiki --level ${i}`, css.headerRowButton); + + const belowLabel = s.showBelow ? + `}` : + `{`; + + buttons += makeButton(belowLabel, `!wiki --below ${s.showBelow ? "false" : "true"}`, css.headerRowButton); + + return `
    ${buttons}
    `; + }; + + const buildKeywordRow = (level, isGM = true) => + { + try + { + const css = getCSS(); + const s = getState(isGM); + const mode = s.mode === "pins" ? "pins" : "handout"; + + s.keywordData[mode] = s.keywordData[mode] || {}; + s.filters[mode] = s.filters[mode] || {}; + s.keywordData[mode][level] = s.keywordData[mode][level] || []; + s.filters[mode][level] = s.filters[mode][level] || []; + + const words = s.keywordData[mode][level]; + let html = ""; + + if(words.length) + { + html += `${level} Keywords: `; + html += makeButton("Clear All", `!wiki --clear-${level}`, css.keywordButton); + + words.forEach(k => + { + const active = s.filters[mode][level].includes(k); + html += makeButton(k, `!wiki --filter-${level} ${k}`, + `${css.keywordButton}${active ? ' font-weight:bold; background:#aaa;' : ''}`); + }); + } + + return `
    ${html}
    `; + } + catch(e) + { + log(`Wiki buildKeywordRow ERROR: ${e}`); + return ""; + } + }; + +const buildHeaderList = (htmlContent, isGM = true) => +{ + const css = getCSS(); + const s = getState(isGM); + if(!s.selectedHandout) return ""; + + const effectiveLevel = s.selectedHeaderLevel === 0 + ? getHighestHeaderLevel((htmlContent || "") + (isGM ? (s._cachedGmNotes || "") : "")) + : s.selectedHeaderLevel; + + const buildButtons = (html, field, isGMField) => + { + if(!html) return { buttons: "", headers: [] }; + + const headerRegex = /<(h[1-4])\b[^>]*>([\s\S]*?)<\/\1>/gi; + let match; + let buttons = ""; + const orderedHeaders = []; + + while((match = headerRegex.exec(html)) !== null) + { + const level = parseInt(match[1][1], 10); + const text = match[2].replace(/<[^>]+>/g, '').trim(); + if(!text) continue; + + if(s.showBelow ? level < effectiveLevel : level !== effectiveLevel) continue; + + const start = match.index; + const remainder = html.slice(headerRegex.lastIndex); + const stopRegex = new RegExp(` sectionH5.includes(x)); + const passH6 = !f.h6.length || f.h6.some(x => sectionH6.includes(x)); + if(!passH5 || !passH6) continue; + + orderedHeaders.push({ text, field }); + + const encoded = encodeURIComponent(text); + const fieldArg = isGMField ? ` --field gmnotes` : ''; + const isActive = s.currentItemType === "header" && s.currentItemId === text && s.mode === "handout"; + const activeStyle = isActive ? " outline:2px solid #ddd; outline-offset:1px;" : ""; + + let levelStyle = ""; + switch(level) + { + case 1: levelStyle = isGMField ? css.h1ButtonGM : css.h1Button; break; + case 2: levelStyle = isGMField ? css.h2ButtonGM : css.h2Button; break; + case 3: levelStyle = isGMField ? css.h3ButtonGM : css.h3Button; break; + case 4: levelStyle = isGMField ? css.h4ButtonGM : css.h4Button; break; + } + + buttons += makeButton(text, `!wiki --show ${encoded}${fieldArg}`, css.listButtonBase + levelStyle + activeStyle); + } + + return { buttons, headers: orderedHeaders }; + }; + + const notesResult = buildButtons(htmlContent, 'notes', false); + let allButtons = notesResult.buttons; + let allHeaders = notesResult.headers; + + if(isGM && s.selectedHandout) + { + const handout = getObj('handout', s.selectedHandout); + if(handout) + { + const gmContent = s._cachedGmNotes || ""; + const gmResult = buildButtons(gmContent, 'gmnotes', true); + allButtons += gmResult.buttons; + allHeaders = allHeaders.concat(gmResult.headers); + } + } + + s.currentList = allHeaders; + return allButtons; +}; + + const buildLeftPanel = async (htmlContent, playerid, isGM = true) => + { + const s = getState(isGM); + let html = buildModeRow(isGM); + + if(s.mode === "handout") + { + html += buildHandoutSelector(isGM); + html += buildHeaderRow(isGM); + html += `
    `; + html += buildHeaderList(htmlContent, isGM); + html += `
    `; + } + else if(s.mode === "pins") + { + html += `
    `; + html += await buildPinList(playerid, isGM); + html += `
    `; + } + + return html; + }; + + const buildHeaderHTML = (isGM = true) => + { + const css = getCSS(); + const title = isGM ? scriptName : "Wiki"; + let html = `
    ${title}${isGM ? helpButton : ""}${homeButton}
    `; + html += buildKeywordRow('h5', isGM); + html += buildKeywordRow('h6', isGM); + return html; + }; + + const buildControlBar = (isGM = true) => + { + const s = getState(isGM); + const css = getCSS(); + + if(!s.currentItemType || !s.displayHTML) return ""; + + let buttons = ""; + + + + + if(s.navBack.length && s.mode !== "pins") + buttons += makeButton("◀ Back", "!wiki --nav-back", css.controlButton); + + if((s.navBack.length || s.navForward.length) && s.mode !== "pins") + buttons += makeButton("✕ History", "!wiki --nav-clear", css.controlButton); + + if(s.navForward.length && s.mode !== "pins") + buttons += makeButton("Forward ▶", "!wiki --nav-forward", css.controlButton); + + if(s.currentIndex >= 0) + { + buttons += makeButton("Previous", "!wiki --prev", css.controlButton); + buttons += makeButton("Next", "!wiki --next", css.controlButton); + } + + // Ping buttons for currently focused pin + if(s.mode === "pins" && s.currentItemType === "pin" && s.currentItemId) + { + const pin = getObj("pin", s.currentItemId); + if(pin) + { + if(isGM) + buttons += makeButton("@", `!wiki --ping-pin-gm ${s.currentItemId}`, css.pinPingButtonGM + "float:right; margin-left:10px;font-size:24px;"); + } buttons += makeButton("@", `!wiki --ping-pin ${s.currentItemId}`, css.pinPingButton + "float:right; margin-left:10px;font-size:24px;"); + } + + if(isGM) + { + if(s.currentItemType === "header") + { + const url = `http://journal.roll20.net/handout/${s.selectedHandout}`; + buttons += `Open`;//span necessary to override Roll20 default text color styling. + + } + else if(s.currentItemType === "pin") + { + const pin = getObj("pin", s.currentItemId); + + if(pin && pin.get("link")) + { + const url = `http://journal.roll20.net/handout/${pin.get("link")}`; + buttons += `Open`;//span necessary to override Roll20 default text color styling. + + } + else + { + buttons += makeButton("Edit", "!wiki --edit", css.controlButton); + } + } + + if(s.mode === "pins" && ( + (typeof pintool !== 'undefined') || + (typeof API_Meta !== 'undefined' && API_Meta.PinTool) +)) + {buttons += makeButton("Pintool", "!wiki --pintool", css.controlButton)}; + + /* + if(s.mode !== "pins"){ + let handoutForDisplay = getObj("handout", s.selectedHandout); + buttons += `
    ${handoutForDisplay.get("name")}
    `; + } + */ + + buttons += `Send to Chat: `; + buttons += makeButton("GM", "!wiki --send-chat", css.controlButton); + buttons += makeButton("Players", "!wiki --send-chat-players", css.controlButton); + //buttons += makeButton("Send to Chat", "!wiki --send-chat", css.controlButton); + } + + return `
    ${buttons}
    `; + }; + +const buildViewportHTML = async (htmlContent, playerid, isGM = true) => +{ + const css = getCSS(); + const s = getState(isGM); + const leftHTML = await buildLeftPanel(htmlContent, playerid, isGM); + const headerHTML = buildHeaderHTML(isGM); + const controlBar = buildControlBar(isGM); + + const bgURL = getWikiBackgroundURL(); + const containerStyle = bgURL + ? css.container + `background-image:url(${bgURL}); background-repeat:repeat;` + : css.container; + + let html = `
    `; + html += headerHTML; + html += ``; + html += ``; + html += ``; + + return html; +}; + /* ============================================================ + * VIEWPORT HANDOUT UPDATE + * ============================================================ */ + + const updateViewportHandout = async (htmlContent = "", playerid, isGM = true) => + { + try + { + const targetName = isGM ? VIEWPORT_HANDOUT_NAME : WIKI_HANDOUT_NAME; + let handout = findObjs({ _type: 'handout', name: targetName })[0]; + + if(!handout) + { + handout = createObj('handout', { name: targetName }); + if(!handout) + { + log(`!wiki ERROR: Could not create handout "${targetName}"`); + return; + } + handout.set('tags', JSON.stringify(['wiki-'])); + if(!isGM) handout.set('inplayerjournals', 'all'); + } + + let viewportHTML = ""; + try + { + viewportHTML = await buildViewportHTML(htmlContent, playerid, isGM); + } + catch(err) + { + log(`!wiki ERROR: buildViewportHTML threw: ${err}`); + viewportHTML = htmlContent; + } + + if(handout && handout.id) + handout.set({ notes: viewportHTML }); + else + log(`!wiki ERROR: handout is invalid or missing id`); + } + catch(e) + { + log(`!wiki ERROR: updateViewportHandout unexpected error: ${e}`); + } + }; + + /* ============================================================ + * ON READY + * ============================================================ */ + + on('ready', async () => + { + initializeState(); + + // Invalidate handout selector cache on any handout change + on('change:handout', () => + { + state.Wiki.gm._handoutSelectorCache = null; + state.Wiki.player._handoutSelectorCache = null; + }); + + await new Promise(resolve => setTimeout(resolve, 2000)); + + const homeHandout = getOrCreateWikiHome(); + if(!homeHandout) return; + + const gmState = state.Wiki.gm; + gmState.mode = "handout"; + + try + { + const content = await new Promise(resolve => + { + homeHandout.get('notes', async notes => + { + await withTimeout(new Promise(resolveGM => + { + homeHandout.get('gmnotes', gmContent => + { + gmState._cachedGmNotes = (gmContent && gmContent !== 'undefined' && gmContent !== 'null') + ? gmContent : ""; + resolveGM(); + }); + }), 1000, 'cachedGmNotes ready').catch(() => { gmState._cachedGmNotes = ""; }); + + resolve(notes); + }); + }); + + gmState.selectedHandout = homeHandout.id; + gmState.selectedHeaderLevel = 0; + gmState.currentList = []; + gmState.currentIndex = -1; + gmState.currentItemType = "header"; + gmState.currentItemId = '__all__'; + gmState.navBack = []; + gmState.navForward = []; + gmState.displayHTML = rewriteHandoutLinks( + getHandoutAvatarHTML(homeHandout.id) + + (await getHandoutSectionHTML(homeHandout.id, '__all__', 'notes', true) || "") + ); + + await updateViewportHandout(content, null, true); + log('Wiki: initialised to home page'); + } + catch(e) + { + log(`Wiki: on('ready') error: ${e}`); + } + }); + + +const getWikiBackgroundStyle = () => +{ + const bgURL = getWikiBackgroundURL(); + return bgURL + ? `background-image:url(${bgURL}); background-repeat:repeat;` + : "background-color:#ccc;"; +}; + + /* ============================================================ + * CHAT HANDLER + * ============================================================ */ + + on('chat:message', async msg => + { + if(msg.type !== 'api' || !msg.content.startsWith('!wiki')) + return; + + initializeState(); + const isGM = playerIsGM(msg.playerid); + const s = isGM ? state.Wiki.gm : state.Wiki.player; + const args = parseArgs(msg.content); + + if(args.help) + { + if(isGM) handleWikiHelp(msg); + return; + } + + s.currentPage = getPageForPlayer(msg.playerid); + + // ===================================================== + // PLAIN !wiki → ALWAYS GO TO WIKI HOME + // ===================================================== + if(!msg.content.includes('--')) + { + const homeHandout = getOrCreateWikiHome(); + if(homeHandout) args.selectHandout = homeHandout.id; + + const targetName = isGM ? VIEWPORT_HANDOUT_NAME : WIKI_HANDOUT_NAME; + const outputHandout = findObjs({ _type: 'handout', name: targetName })[0]; + + if(outputHandout) + { + const openURL = `http://journal.roll20.net/handout/${outputHandout.id}`; + + if(isGM) + { + sendStyledMessage(targetName, `[Open the GM Wiki](${openURL})`); + } + else + { + const css = getCSS(); + sendChat( + scriptName, + `/w "${msg.who}" `, + null, { noarchive: true } + ); + } + } + // Fall through to --selectHandout block + } + + // --- home button --- + if(args.home) + { + const homeHandout = getOrCreateWikiHome(); + if(homeHandout) args.selectHandout = homeHandout.id; + } + + // --- mode switch --- + if(args.mode) + { + s.mode = args.mode; + + if(s.mode === "pins") + { + await buildPinKeywords(msg.playerid, isGM); + + const lastPinId = s._lastPinId; + if(lastPinId) + { + const pin = getObj('pin', lastPinId); + if(pin) + { + let content = ""; + + if(pin.get('link')) + { + const handoutId = pin.get('link'); + const subHeader = pin.get('subLink'); + const field = pin.get('subLinkType') === 'headerGM' ? 'gmnotes' : 'notes'; + const resolvedField = (!isGM && field === 'gmnotes') ? 'notes' : field; + + content = await getHandoutSectionHTML(handoutId, subHeader, resolvedField, isGM) + || "(empty linked handout)"; + + if(pin.get('autoNotesType') === 'blockquote') + { + if(isGM) + { + content = content.replace(/(<\/blockquote>)([\s\S]+)/i, '$1
    $2'); + } + else + { + const blockquoteEnd = content.search(/<\/blockquote>/i); + content = blockquoteEnd !== -1 + ? content.slice(0, blockquoteEnd + ''.length) + : "(no player-visible content)"; + } + } + else if(!isGM) + { + content = "(no player-visible content)"; + } + } + else + { + const notes = pin.get('notes') || ""; + const gmNotes = pin.get('gmNotes') || ""; + const safeNotes = notes === "undefined" ? "" : notes; + const safeGmNotes = gmNotes === "undefined" ? "" : gmNotes; + content = safeNotes + (safeGmNotes ? "
    " + safeGmNotes : ""); + if(!content.trim()) content = "(empty pin)"; + } + + s.currentItemType = "pin"; + s.currentItemId = pin.id; + s.currentIndex = s.currentList.indexOf(pin.id); + s.displayHTML = rewriteHandoutLinks(content); + } + } + + await updateViewportHandout("", msg.playerid, isGM); + return; + } + else if(s.mode === "handout" && s.selectedHandout) + { + const handout = getObj('handout', s.selectedHandout); + if(handout) + { + await new Promise(resolve => + { + handout.get('notes', async content => + { + await withTimeout(new Promise(resolveGM => + { + handout.get('gmnotes', gmContent => + { + s._cachedGmNotes = (gmContent && gmContent !== 'undefined' && gmContent !== 'null') + ? gmContent : ""; + resolveGM(); + }); + }), 1000, 'cachedGmNotes modeSwitch').catch(() => { s._cachedGmNotes = ""; }); + + if(s.currentItemId && s.currentItemId !== '__all__' && s.currentItemType === 'header') + { + const field = s._currentField || 'notes'; + const restored = await getHandoutSectionHTML(s.selectedHandout, s.currentItemId, field, isGM); + + if(restored) + { + s.displayHTML = rewriteHandoutLinks(restored); + } + else + { + s.currentItemId = '__all__'; + s.currentIndex = -1; + s.displayHTML = rewriteHandoutLinks( + getHandoutAvatarHTML(s.selectedHandout) + + (await getHandoutSectionHTML(s.selectedHandout, '__all__', 'notes', isGM) || "") + ); + } + } + else + { + s.currentItemType = "header"; + s.currentItemId = '__all__'; + s.currentIndex = -1; + s.displayHTML = rewriteHandoutLinks( + getHandoutAvatarHTML(s.selectedHandout) + + (await getHandoutSectionHTML(s.selectedHandout, '__all__', 'notes', isGM) || "") + ); + } + + await updateViewportHandout(content, msg.playerid, isGM); + resolve(); + }); + }); + } + return; + } + } + + // --- handout selection --- + if(args.selectHandout) + { + const homeHandout = getOrCreateWikiHome(); + const isHomeNavigation = homeHandout && args.selectHandout === homeHandout.id; + + if(args.selectHandout !== s.selectedHandout && !isHomeNavigation) + pushNavState(args.selectHandout, isGM); + + if(isHomeNavigation) + { + s.navBack = []; + s.navForward = []; + } + + s.selectedHandout = args.selectHandout; + const handout = getObj('handout', s.selectedHandout); + + if(handout) + { + await new Promise(resolve => + { + handout.get('notes', async content => + { + s.selectedHeaderLevel = 0; + s.keywordData.handout.h5 = extractKeywords(content, 5); + s.keywordData.handout.h6 = extractKeywords(content, 6); + s.filters.handout.h5 = []; + s.filters.handout.h6 = []; + + await withTimeout(new Promise(resolveGM => + { + handout.get('gmnotes', gmContent => + { + s._cachedGmNotes = (gmContent && gmContent !== 'undefined' && gmContent !== 'null') + ? gmContent : ""; + resolveGM(); + }); + }), 1000, 'cachedGmNotes selectHandout').catch(() => { s._cachedGmNotes = ""; }); + + if(!args.show) + { + s.currentList = []; + s.currentIndex = -1; + s.currentItemType = "header"; + s.currentItemId = '__all__'; + s.displayHTML = rewriteHandoutLinks( + getHandoutAvatarHTML(s.selectedHandout) + + (await getHandoutSectionHTML(s.selectedHandout, '__all__', 'notes', isGM) || "") + ); + + await updateViewportHandout(content, msg.playerid, isGM); + } + + resolve(); + }); + }); + } + + if(!args.show) return; + } + + // --- header level --- +if(args.level) +{ + s.selectedHeaderLevel = parseInt(args.level, 10); + + if(s.selectedHandout) + { + const handout = getObj('handout', s.selectedHandout); + if(handout) + { + handout.get('notes', async content => + { + if(s.selectedHeaderLevel === 0) + { + s.currentList = []; + s.currentIndex = -1; + s.currentItemType = "header"; + s.currentItemId = '__all__'; + s.displayHTML = rewriteHandoutLinks( + getHandoutAvatarHTML(s.selectedHandout) + + (await getHandoutSectionHTML(s.selectedHandout, '__all__', 'notes', isGM) || "") + ); + } + await updateViewportHandout(content, msg.playerid, isGM); + }); + } + return; + } +} + + // --- below toggle --- + if(args.hasOwnProperty('below')) + { + s.showBelow = (args.below === "true"); + if(s.selectedHandout) + { + const handout = getObj('handout', s.selectedHandout); + if(handout) + { + await new Promise(resolve => + { + handout.get('notes', async content => + { + await updateViewportHandout(content, msg.playerid, isGM); + resolve(); + }); + }); + } + } + return; + } + + // --- previous --- + if(args.prev) + { + if(s.currentItemType === "header" && s.selectedHandout) + { + const handout = getObj('handout', s.selectedHandout); + if(handout) + { + const fullContent = await new Promise(resolve => handout.get('notes', resolve)); + s.currentList = getFilteredHeaders(fullContent, s.selectedHeaderLevel, s); + } + } + + if(s.currentIndex > 0) + { + s.currentIndex--; + const target = s.currentList[s.currentIndex]; + const targetText = target.text || target; + const targetField = target.field || 'notes'; + + if(s.currentItemType === "header") + { + args.show = encodeURIComponent(targetText); + args.field = targetField; + } + else + { + args['show-pin'] = target; + } + } + } + + // --- next --- + if(args.next) + { + if(s.currentItemType === "header" && s.selectedHandout) + { + const handout = getObj('handout', s.selectedHandout); + if(handout) + { + const fullContent = await new Promise(resolve => handout.get('notes', resolve)); + s.currentList = getFilteredHeaders(fullContent, s.selectedHeaderLevel, s); + } + } + + if(s.currentIndex < s.currentList.length - 1) + { + s.currentIndex++; + const target = s.currentList[s.currentIndex]; + const targetText = target.text || target; + const targetField = target.field || 'notes'; + + if(s.currentItemType === "header") + { + args.show = encodeURIComponent(targetText); + args.field = targetField; + } + else + { + args['show-pin'] = target; + } + } + } + + // --- show a header --- + if(args.show && s.selectedHandout) + { + const headerText = decodeURIComponent(args.show); + const field = args.field === 'gmnotes' ? 'gmnotes' : 'notes'; + const handout = getObj('handout', s.selectedHandout); + + if(handout) + { + const fullContent = await new Promise(resolve => handout.get('notes', resolve)); + + const crossHandout = args.selectHandout && args.selectHandout !== s.selectedHandout; + if(!crossHandout && s.currentItemId !== headerText) + pushNavState(s.selectedHandout, isGM); + + s.currentList = getFilteredHeaders(fullContent, s.selectedHeaderLevel, s); + s.currentIndex = s.currentList.findIndex(item => (item.text || item) === headerText); + s.currentItemType = "header"; + s.currentItemId = headerText; + s.displayHTML = rewriteHandoutLinks( + await getHandoutSectionHTML(s.selectedHandout, headerText, field, isGM) + || "Section not found." + ); + + await updateViewportHandout(fullContent, msg.playerid, isGM); + } + + return; + } + + // --- clear history --- + if(args['nav-clear']) + { + s.navBack = []; + s.navForward = []; + if(isGM) + sendStyledMessage("Navigation history cleared."); + else + sendChat(scriptName, `/w "${msg.who}" Navigation history cleared.`, null, { noarchive: true }); + return; + } + + // --- show a pin --- + if(args['show-pin']) + { + const pin = getObj('pin', args['show-pin']); + if(pin) + { + let content = ""; + + if(pin.get('link')) + { + const handoutId = pin.get('link'); + const subHeader = pin.get('subLink'); + const field = pin.get('subLinkType') === 'headerGM' ? 'gmnotes' : 'notes'; + const resolvedField = (!isGM && field === 'gmnotes') ? 'notes' : field; + + content = await getHandoutSectionHTML(handoutId, subHeader, resolvedField, isGM) + || "(empty linked handout)"; + + if(pin.get('autoNotesType') === 'blockquote') + { + if(isGM) + { + content = content.replace(/(<\/blockquote>)([\s\S]+)/i, '$1
    $2'); + } + else + { + const blockquoteEnd = content.search(/<\/blockquote>/i); + content = blockquoteEnd !== -1 + ? content.slice(0, blockquoteEnd + ''.length) + : "(no player-visible content)"; + } + } + else if(!isGM) + { + content = "(no player-visible content)"; + } + } + + s.displayHTML = rewriteHandoutLinks(content); + s.currentItemType = "pin"; + s.currentItemId = pin.id; + s._lastPinId = pin.id; + s.currentIndex = s.currentList.indexOf(pin.id); + + await updateViewportHandout("", msg.playerid, isGM); + } + + return; + } + + // --- audit pins --- + if(args['audit-pins']) + { + if(!isGM) return; + + const pageid = getPageForPlayer(msg.playerid); + if(!pageid) + { + sendStyledMessage("Audit Pins", "No current page found."); + return; + } + + const pins = findObjs({ _type: 'pin', _pageid: pageid }); + const issues = []; + + for(const p of pins) + { + const title = p.get('title') || p.get('subLink') || p.id; + const visibleTo = p.get('visibleTo'); + const link = p.get('link'); + const autoNotes = p.get('autoNotesType'); + + if(visibleTo === 'all' && link && autoNotes !== 'blockquote') + issues.push({ id: p.id, title }); + } + + if(!issues.length) + { + sendStyledMessage("Audit Pins", "No issues found on this page. All player-visible linked pins have autoNotesType set correctly."); + return; + } + + const css = getCSS(); + let body = `${issues.length} issue(s) found on this page:

    `; + + issues.forEach(issue => + { + body += `${makeButton("Fix", `!wiki --fix-pin ${issue.id}`, css.messageButton)} ${issue.title} — autoNotesType not set to "blockquote"
    `; + }); + + const allIds = issues.map(i => i.id).join(','); + body += `
    ${makeButton("Fix All", `!wiki --fix-pin-all ${allIds}`, css.messageButton)} Fix all ${issues.length} pins on this page`; + + sendStyledMessage("Audit Pins", body); + return; + } + + // --- fix pin --- + if(args['fix-pin']) + { + if(!isGM) return; + const pin = getObj('pin', args['fix-pin']); + if(pin) + { + pin.set('autoNotesType', 'blockquote'); + sendStyledMessage("Audit Pins", `Fixed: ${pin.get('title') || pin.get('subLink') || pin.id}`); + } + return; + } + + // --- fix pin all --- + if(args['fix-pin-all']) + { + if(!isGM) return; + const ids = args['fix-pin-all'].split(','); + let fixed = 0; + ids.forEach(id => + { + const pin = getObj('pin', id.trim()); + if(pin) { pin.set('autoNotesType', 'blockquote'); fixed++; } + }); + sendStyledMessage("Audit Pins", `Fixed ${fixed} pin(s) on this page.`); + return; + } + + // --- ping a pin --- + if(args['ping-pin']) + { + const pin = getObj('pin', args['ping-pin']); + if(pin) sendPing(pin.get('x'), pin.get('y'), pin.get('_pageid'), msg.playerid, true); + return; + } + + // --- ping a pin gm --- + if(args['ping-pin-gm']) + { + const pin = getObj('pin', args['ping-pin-gm']); + if(pin) sendPing(pin.get('x'), pin.get('y'), pin.get('_pageid'), "", true, msg.playerid); + return; + } + + // --- edit --- + if(args.edit) + { + if(s.currentItemType === "header") + { + sendChat("", `/w gm Open Handout`); + } + else if(s.currentItemType === "pin") + { + const pin = getObj("pin", s.currentItemId); + if(pin && pin.get("linkType") === "handout") + sendChat("", `/w gm Open Handout`); + } + return; + } + + // --- send to chat --- +if(args['send-chat'] && s.displayHTML) +{ + const bgStyle = getWikiBackgroundStyle(); + sendChat("", `/w gm
    ${s.displayHTML}
    `); + return; +} + +if(args['send-chat-players'] && s.selectedHandout) +{ + if(!isGM) return; + + // Build player-safe content using the same logic as the player wiki + let playerContent = ""; + + if(s.currentItemType === "pin" && s.currentItemId) + { + const pin = getObj('pin', s.currentItemId); + if(pin && pin.get('link')) + { + const handoutId = pin.get('link'); + const subHeader = pin.get('subLink'); + const field = pin.get('subLinkType') === 'headerGM' ? 'gmnotes' : 'notes'; + const resolvedField = field === 'gmnotes' ? 'notes' : field; + + playerContent = await getHandoutSectionHTML(handoutId, subHeader, resolvedField, false) + || "(no player-visible content)"; + + if(pin.get('autoNotesType') === 'blockquote') + { + const blockquoteEnd = playerContent.search(/<\/blockquote>/i); + if(blockquoteEnd !== -1) + playerContent = playerContent.slice(0, blockquoteEnd + ''.length); + else + playerContent = "(no player-visible content)"; + } + } + } + else if(s.currentItemType === "header" && s.selectedHandout) + { + const headerText = s.currentItemId === '__all__' ? null : s.currentItemId; + playerContent = await getHandoutSectionHTML(s.selectedHandout, headerText || '__all__', 'notes', false) + || "(no player-visible content)"; + } + + if(!playerContent) + { + sendStyledMessage("Send to Players", "No player-visible content to send."); + return; + } + + const bgStyle = getWikiBackgroundStyle(); + sendChat( + scriptName, + `
    ${rewriteHandoutLinks(playerContent)}
    ` + ); + return; +} + + // --- pintool --- + if(args.pintool) + { + sendChat("", "!pintool"); + return; + } + + // --- filter keywords (toggle) --- + let filtersChanged = false; + const mode = s.mode === "pins" ? "pins" : "handout"; + + ['h5', 'h6'].forEach(level => + { + if(args[`filter-${level}`]) + { + const word = normalizeWord(args[`filter-${level}`]); + const idx = s.filters[mode][level].indexOf(word); + if(idx === -1) s.filters[mode][level].push(word); + else s.filters[mode][level].splice(idx, 1); + filtersChanged = true; + } + + if(args[`clear-${level}`]) + { + s.filters[mode][level] = []; + filtersChanged = true; + } + }); + + if(filtersChanged && s.selectedHandout) + { + const handout = getObj('handout', s.selectedHandout); + if(handout) + { + const content = await new Promise(resolve => handout.get('notes', resolve)); + s.displayHTML = ""; + await updateViewportHandout(content, msg.playerid, isGM); + } + return; + } + + // --- nav back --- + if(args['nav-back'] && s.navBack.length) + { + s.navForward.push({ handoutId: s.selectedHandout, headerText: s.currentItemId || null }); + + const target = s.navBack.pop(); + s.selectedHandout = target.handoutId; + + const handout = getObj('handout', s.selectedHandout); + if(handout) + { + await new Promise(resolve => + { + handout.get('notes', async content => + { + s.keywordData.handout.h5 = extractKeywords(content, 5); + s.keywordData.handout.h6 = extractKeywords(content, 6); + + let restoredLevel = 1; + if(target.headerText) + { + for(let i = 1; i <= 4; i++) + { + if(extractHeaders(content, i).includes(target.headerText)) + { + restoredLevel = i; + break; + } + } + } + else + { + restoredLevel = getHighestHeaderLevel(content); + } + + s.selectedHeaderLevel = restoredLevel; + s.currentList = getFilteredHeaders(content, s.selectedHeaderLevel, s); + s.currentIndex = target.headerText ? s.currentList.indexOf(target.headerText) : 0; + s.currentItemType = "header"; + s.currentItemId = target.headerText || s.currentList[0] || null; + s.displayHTML = rewriteHandoutLinks( + await getHandoutSectionHTML(s.selectedHandout, s.currentItemId, 'notes', isGM) || "" + ); + + await updateViewportHandout(content, msg.playerid, isGM); + resolve(); + }); + }); + } + return; + } + + // --- nav forward --- + if(args['nav-forward'] && s.navForward.length) + { + s.navBack.push({ handoutId: s.selectedHandout, headerText: s.currentItemId || null }); + + const target = s.navForward.pop(); + s.selectedHandout = target.handoutId; + + const handout = getObj('handout', s.selectedHandout); + if(handout) + { + await new Promise(resolve => + { + handout.get('notes', async content => + { + s.keywordData.handout.h5 = extractKeywords(content, 5); + s.keywordData.handout.h6 = extractKeywords(content, 6); + + let restoredLevel = 1; + if(target.headerText) + { + for(let i = 1; i <= 4; i++) + { + if(extractHeaders(content, i).includes(target.headerText)) + { + restoredLevel = i; + break; + } + } + } + else + { + restoredLevel = getHighestHeaderLevel(content); + } + + s.selectedHeaderLevel = restoredLevel; + s.currentList = getFilteredHeaders(content, s.selectedHeaderLevel, s); + s.currentIndex = target.headerText ? s.currentList.indexOf(target.headerText) : 0; + s.currentItemType = "header"; + s.currentItemId = target.headerText || s.currentList[0] || null; + s.displayHTML = rewriteHandoutLinks( + await getHandoutSectionHTML(s.selectedHandout, s.currentItemId, 'notes', isGM) || "" + ); + + await updateViewportHandout(content, msg.playerid, isGM); + resolve(); + }); + }); + } + return; + } + + // --- default update (only when no other arg was handled) --- + if(s.selectedHandout && + !args.selectHandout && !args.show && !args.home && + !args.level && !args.mode && !args.below && + !args['nav-back'] && !args['nav-forward'] && !args['nav-clear'] && + !args.prev && !args.next && !args['show-pin'] && + !args.edit && !args['send-chat'] && !args.pintool && + !args['audit-pins'] && !args['fix-pin'] && !args['fix-pin-all'] && + !args['ping-pin'] && !args['ping-pin-gm'] && !args['filter-h5'] && + !args['filter-h6'] && !args['clear-h5'] && !args['clear-h6']) + { + const handout = getObj('handout', s.selectedHandout); + if(handout) + { + handout.get('notes', async content => + { + await updateViewportHandout(content, msg.playerid, isGM); + }); + return; + } + } + + await updateViewportHandout("", msg.playerid, isGM); + }); + +})(); + diff --git a/Wiki/readme.md b/Wiki/readme.md new file mode 100644 index 000000000..9cebb5aff --- /dev/null +++ b/Wiki/readme.md @@ -0,0 +1,206 @@ +# Wiki + +A unified interface for browsing and navigating campaign information inside Roll20. + +The Wiki consolidates handouts and map pins into a single, interactive panel with navigation controls, filtering, and history tracking. It is designed to reduce context switching and improve information accessibility during play. + +--- + +## Overview + +The Wiki creates two automatically managed handouts: + +- **Wiki - GM** — full interface, includes GM Notes and all content +- **Wiki - Player** — player-facing interface with restricted visibility + +Both update dynamically as users interact with the system. + +--- + +## Getting Started + +Type: + +`!wiki` + +- GMs receive a link to **Wiki - GM** +- Players receive a link to **Wiki - Player** + +The interface contains: + +- **Navigation Panel** (left): selectable items and filters +- **Content Panel** (right): displays selected content + +--- + +## Wiki Home + +- `!wiki` always returns to **Wiki Home** +- The Home (⌂) button clears navigation history + +**Wiki Home** is a normal handout: +- Use it as a campaign dashboard +- Links inside it open within the Wiki interface + +### Background Image + +Add an `https://` image URL as a **tag** on Wiki Home: +- The first valid URL is used +- The image tiles across the interface + +--- + +## Handout Mode + +Handout Mode displays journal entries with structured navigation. + +### Navigation Behavior + +- All headers in a handout appear in the navigation panel +- Clicking a header jumps to that section +- The active section is highlighted + +### Header Controls + +- **All** — full handout view +- **H1–H4** — filter by header level +- **{ toggle** — include lower-level headers + +### GM Notes Support + +- GM-only headers appear in grey +- Selecting them loads GM Notes content +- Notes and GM Notes are separated in the content panel + +### Avatars + +- Displayed at top when viewing full handout (All mode) + +### Internal Links + +- Handout links are rewritten to stay inside the Wiki + +--- + +## Keywords (H5–H6) + +Keywords act as filters. + +Define them using headers: + + +H2 Abandoned Mine +H5 dungeon +H6 goblins + + +Behavior: +- Clicking a keyword filters sections +- Multiple keywords can be active +- Use **Clear All** to reset + +--- + +## Pin Mode + +Pin Mode displays all pins on the current page. + +### Features + +- Pins are listed alphabetically +- Selecting a pin loads its content +- Last selected pin is remembered + +### Content Sources + +- Direct pin notes +- Linked handout sections + +### Player Visibility + +- Blockquotes define player-visible content +- Content after blockquote is GM-only +- Pins without blockquotes show nothing to players + +### Audit Tool + + +!wiki --audit-pins + + +- Detects improperly configured pins +- Provides Fix / Fix All options + +### Ping Controls + +Each pin includes two ping buttons: + +- **Gold @** — ping visible to all players +- **Grey @** — GM-only ping + +--- + +## Content Controls + +Located above the content panel: + +- **Back / Forward** — navigation history +- **Clear History** — reset navigation stack +- **Previous / Next** — step through filtered list +- **Edit** (GM) — open source handout +- **Send to Chat** (GM) — output content unfiltered +- **Pintool** (GM) — opens Pintool if installed (Pin Mode only) + +History is tracked separately for GM and players. + +--- + +## Player Access + +Players use: + +`!wiki` + +They can access: + +- Shared handouts +- Handouts tagged with `wiki+` + +--- + +## Tags + +- `wiki+` — force visibility to players +- `wiki-` — hide from Wiki interface +- `Image URL` — Add a background image to the wiki display + +--- + +## Commands + +- `!wiki` — open Wiki +- `!wiki --help` — open help +- `!wiki --audit-pins` — audit current page pins + +--- + +## Best Practices + +- Use **H1–H4** for structure +- Use **H5–H6** for keywords +- Keep keywords consistent +- Use blockquotes to separate player/GM content +- Assign avatars to important handouts +- Build a dashboard in Wiki Home +- Use `wiki+` for selective sharing +- Use `wiki-` to hide system handouts + +--- + +## Optional Integration + +### Pintool + +If Pintool is installed: +- A Pintool button appears in Pin Mode +- Provides enhanced pin management \ No newline at end of file diff --git a/Wiki/script.json b/Wiki/script.json new file mode 100644 index 000000000..ef857e2ab --- /dev/null +++ b/Wiki/script.json @@ -0,0 +1,14 @@ +{ + "name": "Wiki", + "script": "Wiki.js", + "version": "1.0.0", + "description": "A unified interface for browsing Roll20 handouts and map pins with navigation, filtering, and history tracking.", + "authors": "Keith Curtis", + "roll20userid": "162065", + "modifies": { + "handout": "read,write" + }, + "dependencies": [], + "conflicts": [], + "previousversions": ["1.0.0"] +}
    ${leftHTML}${controlBar}${s.displayHTML || ""}