diff --git a/Portal/1.0.0/Portal.js b/Portal/1.0.0/Portal.js
new file mode 100644
index 000000000..c0f7072d5
--- /dev/null
+++ b/Portal/1.0.0/Portal.js
@@ -0,0 +1,829 @@
+var API_Meta = API_Meta || {};
+API_Meta.Portal = {
+ offset: Number.MAX_SAFE_INTEGER,
+ lineCount: -1
+}; {
+ try {
+ throw new Error('');
+ } catch (e) {
+ API_Meta.Portal.offset = (parseInt(e.stack.split(/\n/)[1].replace(/^.*:(\d+):.*$/, '$1'), 10) - (4));
+ }
+}
+//Portal - A sccript to control and convert doors and windows.
+on('ready', () => {
+
+ const version = '1.0.0'; //version number set here
+ log('-=> Portal v' + version + ' is loaded. Command !portal creates chat menu to convert and control doors and windows.');
+
+
+
+ const CMD = '!portal';
+let testthingie = "werwers"
+
+ const DEFAULT_COLORS = {
+ door: '#ff9900',
+ window: '#00bfff'
+ };
+
+ const WINDOW_PROPS = ['color','x','y','isopen','islocked', 'isshuttered'];
+ const DOOR_PROPS = ['color','x','y','isopen','islocked','issecret'];
+
+ const DOOR_DEFAULTS = { color: DEFAULT_COLORS.door, x: 0, y: 0, isOpen: false, isLocked: false, isSecret: false };
+ const WINDOW_DEFAULTS = { color: DEFAULT_COLORS.window, x: 0, y: 0, isOpen: false, isLocked: false , isShuttered: false };
+
+ // ---------- Utilities ----------
+
+
+// ############################################################
+// IF YOU NEED TO DIABLE THE HELP SYSTEM, MAKE THIS FALSE
+// ############################################################
+const PORTAL_HELP_ENABLED = true; //MAKE FALSE FO PRODUCTIONWIZARD
+
+const PORTAL_HELP_NAME = "Help: Portal";
+const PORTAL_HELP_AVATAR = "https://files.d20.io/images/442783616/hDkduTWDpcVEKomHnbb6AQ/max.png?45596439";
+
+const PORTAL_HELP_TEXT = `
+
Portal Script Help
+
+This script manages doors and windows (“portals”), allowing you to:
+
+- Create portals on the map
+- Lock / unlock them
+- Toggle open/closed state
+- Shutter / unshutter windows
+- Hide / unhide doors
+- Convert Doors and Windows from paths or pathv2 objects
+- Bulk-select and modify portal Properties
+
+
+Base Command: !portal
+
+Conversion Commands
+
+ --convertwindow — Convert selected doors or paths into windows.
+ --convertdoor — Convert selected windows or paths into doors.
+ --convertall — Apply the same conversion to all similar objects:
+
+ - Doors → all doors of same color
+ - Windows → all windows of same color
+ - Paths → all paths matching color and barrierType
+
+
+
+
+Attribute Commands
+Format: --attributeName|value
+
+Values are case-insensitive. Booleans accept:
+
+ - true: true, yes, on
+ - false: false, no, off
+ - flip: toggles true/false
+
+
+Common Door/Window Attributes
+
+ --isLocked|true/false/flip
+ --isOpen|true/false/flip
+ --isSecret|true/false/flip
+ --isShuttered|true/false/flip
+ --color|#rrggbb
+ --color|default — sets selected doors or windows to Roll20 defaults
+ --key|string
+
+
+Position Attributes
+Use + or − prefixes for relative moves:
+
+ --x|100 — set position
+ --x|+20 — move right 20 units
+ --x|-20 — move left 20 units
+ --y|100 — set position
+ --y|+10 — move down 10 units
+
+Note: All Y and Y values are figured with the top left corned of the map being (0,0), and positive values increasing toward the lower right, to conform with how graphics are handled.
+
+Path Handling
+
+ - Only paths with exactly two endpoints are converted.
+ - Paths with more points are skipped and noted in chat.
+ - Position is taken from the path endpoints.
+
+
+General Rules
+
+ - All commands are case-insensitive.
+ - All provided attributes apply to every selected object.
+ - Missing attributes (e.g.,
isSecret on windows) are ignored.
+
+
+Examples
+
+ !portal --convertwindow
+ !portal --isLocked|true
+ !portal --isLocked|flip --isOpen|false
+ !portal --x|+20 --y|-10
+ !portal --convertwindow --color|#FF00FF --isLocked|true
+
+`;
+
+// === HELP COMMAND HANDLER ===
+const handleHelp = (msg) => {
+ if (!PORTAL_HELP_ENABLED) return; // <---- NEW DISABLER
+
+ if (msg.type !== "api") return;
+
+ const args = msg.content.split(/\s+--/);
+ if (!args[1] || !args[1].toLowerCase().startsWith("help")) return;
+
+ // Find existing handout
+ let handout = findObjs({
+ _type: "handout",
+ name: PORTAL_HELP_NAME
+ })[0];
+
+ // Create if missing
+ if (!handout) {
+ handout = createObj("handout", {
+ name: PORTAL_HELP_NAME,
+ archived: false
+ });
+
+ // Set avatar
+ handout.set("avatar", PORTAL_HELP_AVATAR);
+ }
+
+ // Always overwrite contents
+ handout.set("notes", PORTAL_HELP_TEXT);
+
+ // Create GM whisper with styled box
+ const link = `http://journal.roll20.net/handout/${handout.get("_id")}`;
+
+ const box = `
+`.trim().replace(/\r?\n/g, '');
+
+ sendChat("Portal", `/w gm ${box}`);
+};
+
+// Register help handler
+on("chat:message", (msg) => {
+ if (!PORTAL_HELP_ENABLED) return; // <---- NEW DISABLER
+ handleHelp(msg);
+});
+
+function resolveColor(rawColorValue, targetType) {
+ if (!rawColorValue) return null;
+ const v = String(rawColorValue).trim().toLowerCase();
+
+ if (v === 'default') {
+ return (targetType === 'door')
+ ? DEFAULT_COLORS.door
+ : DEFAULT_COLORS.window;
+ }
+
+ return normalizeColor(rawColorValue);
+}
+
+
+// ############################################################
+// END HELP HANDOUT CREATION SELECTION
+// COMMENT THIS SECTION OUT TO DISABLE HELP SYSTEM
+// ############################################################
+
+// --- Chat menu (whispered to GM) ---
+// Insert this block into the script (after your constants / helper defs)
+// --- CHAT MENU + MENU STATE PATCH ---
+// Add these near the other globals (CSS, etc.). It defines menu CSS + helpers + handlers.
+// It intentionally uses a new API command "!portal-mode" to toggle Selected/All to avoid
+// interfering with the main !portal handler.
+
+const CSS = {
+ container: 'position:relative; left:-20px; width:100%; border:1px solid #111; background:#ddd; color:#111; padding:6px; margin:4px; border-radius:6px; font-size:13px; line-height:1.5;',
+ title: 'width:100%; border:none; background:#444; padding:1px; margin-bottom:5px; border-radius:4px; font-size:14px; line-height:1.5; color:#eee; font-weight:bold; text-align:center;',
+ label: 'display:inline-block; font-weight:bold; margin:4px 6px 0 0; width:60px;',
+ button: 'box-shadow:inset 0px 1px 3px 0px #555; background:linear-gradient(to bottom, #333 5%, #555 100%); background-color:#444; border-radius:4px; min-width:10px; text-align:center; border:1px solid #566963; display:inline-block; cursor:pointer; color:#eee; font-size:13px; font-weight:bold; padding:1px 5px; margin:1px; text-decoration:none; text-shadow:0px -1px 0px #2b665e;',
+ active: 'font-weight:bold !important; background:#555;',
+ // new toggle-specific styles (kept inline in CSS object per requirement)
+ toggleWrap: 'text-align:center;margin-bottom:6px;',
+ toggleBtn: 'width:40%; display:inline-block;padding:4px 8px;margin:0 4px;border-radius:4px;cursor:pointer;border:1px solid #333;background:#aaa;color:#fff;font-weight:bold;text-decoration:none;',
+ toggleActive: 'width:40%; background:#444;color:#eee;border:1px solid #566963;' // "Roll20 Pink" look for active state
+};
+
+const buildBtn = (href, label, style, tooltip) =>
+ `${label}`;
+
+// Helper: append --all when menu mode is 'all'
+function buildCmd(base, mode) {
+ if (mode === 'all') {
+ // avoid double-space if already has args
+ return base + ' --all';
+ }
+ return base;
+}
+
+// Ensure persistent state exists
+if (!state.portal_menu) {
+ state.portal_menu = { mode: 'selected' }; // default: selected
+}
+
+// Build the menu HTML (single-block). Always generate based on current state.
+function buildMenuHtml() {
+ const mode = (state.portal_menu && state.portal_menu.mode) ? state.portal_menu.mode : 'selected';
+
+ // toggle button markup
+ const selectedActive = (mode === 'selected') ? CSS.toggleActive : '';
+ const allActive = (mode === 'all') ? CSS.toggleActive : '';
+
+ // helper to build toggle buttons that call the small mode-switcher command
+ const toggleSelected = `Selected Only`;
+ const toggleAll = `All Similar`;
+
+ // Build menu commands using buildCmd(base, mode) so --all will be added when needed
+ const html = `
+
+
Portal
+
+
+ ${toggleSelected}${toggleAll}
+
+
+
+ Convert
+ ${buildBtn(buildCmd('!portal --convertdoor', mode), 'Door', null, 'Convert selected items into doors')}
+ ${buildBtn(buildCmd('!portal --convertwindow', mode), 'Window', null, 'Convert selected items into windows')}
+
+
+
+ Position
+
+ ${buildBtn(buildCmd('!portal --x|?{Enter X value in pixels from top left of page}', mode), 'X', null, 'Set absolute X-position in pixels from top-left')}
+ ${buildBtn(buildCmd('!portal --y|?{Enter Y value in pixels from top left of page}', mode), 'Y', null, 'Set absolute Y-position in pixels from top-left')}
+
+ ${buildBtn(buildCmd('!portal --x|-?{Moving selected items to the left. Enter value in pixels}', mode), '←', null, 'Move left by given number of pixels')}
+ ${buildBtn(buildCmd('!portal --x|+?{Moving selected items to the right. Enter value in pixels}', mode), '→', null, 'Move right by given number of pixels')}
+ ${buildBtn(buildCmd('!portal --y|-?{Moving selected items up. Enter value in pixels}', mode), '↑', null, 'Move up by given number of pixels')}
+ ${buildBtn(buildCmd('!portal --y|+?{Moving selected items down. Enter value in pixels}', mode), '↓', null, 'Move down by given number of pixels')}
+
+
+
+
+ Color
+ ${buildBtn(buildCmd('!portal --color|default', mode), 'Default', null, 'Reset to default portal colors')}
+ ${buildBtn(buildCmd('!portal --color|?{Enter custom color (#rrggbb)}', mode), 'Custom', null, 'Apply a custom color. Must be a hex value starting with #')}
+
+
+
+ Locked
+ ${buildBtn(buildCmd('!portal --isLocked|true', mode), 'Locked', null, 'Set door or window to locked')}
+ ${buildBtn(buildCmd('!portal --isLocked|false', mode), 'Unlocked', null, 'Set door or window to unlocked')}
+ ${buildBtn(buildCmd('!portal --isLocked|flip', mode), 'Toggle', null, 'Toggle locked/unlocked')}
+
+
+
+ Open
+ ${buildBtn(buildCmd('!portal --isOpen|true', mode), 'Open', null, 'Set door or window to open')}
+ ${buildBtn(buildCmd('!portal --isOpen|false', mode), 'Closed', null, 'Set door or window to closed')}
+ ${buildBtn(buildCmd('!portal --isOpen|flip', mode), 'Toggle', null, 'Toggle open/closed')}
+
+
+
+ Doors
+ ${buildBtn(buildCmd('!portal --isSecret|true', mode), 'Hidden', null, 'Set door as secret / hidden')}
+ ${buildBtn(buildCmd('!portal --isSecret|false', mode), 'Visible', null, 'Set door as visible')}
+
+
+
+ Windows
+ ${buildBtn(buildCmd('!portal --isShuttered|true', mode), 'Shuttered', null, 'Set window as shuttered')}
+ ${buildBtn(buildCmd('!portal --isShuttered|false', mode), 'Unshuttered', null, 'Set window as unshuttered')}
+
+
+
+ ${PORTAL_HELP_ENABLED ? `
+
+ Help
+ ${buildBtn('!portal --help', 'Help', null, 'Open the Portal help handout')}
+
` : ``}
+
`.trim();
+return html.replace(/\r?\n/g, '');
+// return html;
+}
+
+
+// Menu requester: exact "!portal" with no args (whisper to GM)
+const handleMenu = (msg) => {
+ if (msg.type !== 'api') return;
+ if (String(msg.content || '').trim().toLowerCase() !== CMD) return;
+
+ const html = buildMenuHtml();
+ sendChat('Portal', `/w gm ${html}`);
+};
+
+// New small handler to update menu mode: "!portal-mode --mode|selected" or "--mode|all"
+const handleMenuMode = (msg) => {
+ if (msg.type !== 'api') return;
+ if (!msg.content.toLowerCase().startsWith('!portal-mode')) return;
+
+ // parse simple --mode|value
+ const pieces = msg.content.split(/\s+--/).slice(1);
+ let newMode = null;
+ for (const raw of pieces) {
+ const firstToken = raw.trim().split(/\s+/)[0] || '';
+ const kv = firstToken.split('|');
+ const key = (kv[0] || '').toLowerCase();
+ const val = kv.slice(1).join('|') || (raw.substring(firstToken.length).trim().startsWith('|') ? raw.substring(firstToken.length).trim().substring(1).trim() : '');
+ if (key === 'mode' && val) {
+ const v = val.toLowerCase();
+ if (v === 'selected' || v === 'all') newMode = v;
+ }
+ }
+
+ if (!newMode) {
+ // invalid usage: re-send menu to show current state
+ sendChat('Portal', `/w gm ${buildMenuHtml()}`);
+ return;
+ }
+
+ // Ensure state object exists and set the mode
+ state.portal_menu = state.portal_menu || {};
+ state.portal_menu.mode = newMode;
+
+ // Re-send the menu so GM sees updated toggles
+ sendChat('Portal', `/w gm ${buildMenuHtml()}`);
+};
+
+// Register both handlers (order doesn't matter)
+on('chat:message', handleMenuMode);
+on('chat:message', handleMenu);
+
+// --- end of chat menu patch ---
+
+
+
+
+ function normalizeColor(c) {
+ if (!c) return null;
+ c = String(c).trim().toLowerCase();
+ if (c.length === 4 && c[0] === '#') {
+ return '#' + c[1] + c[1] + c[2] + c[2] + c[3] + c[3];
+ }
+ return c;
+ }
+
+ function toLowerKey(k) {
+ return String(k || '').trim().toLowerCase();
+ }
+
+ function parseBoolToken(tok) {
+ if (!tok && tok !== '') return null;
+ const t = String(tok).trim().toLowerCase();
+ if (['true','yes','on'].includes(t)) return { mode: 'set', value: true };
+ if (['false','no','off'].includes(t)) return { mode: 'set', value: false };
+ if (t === 'flip') return { mode: 'flip' };
+ return null;
+ }
+
+ function parseNumberToken(tok) {
+ if (tok === undefined || tok === null) return null;
+ const s = String(tok).trim();
+ if (s.length === 0) return null;
+ // relative?
+ if (/^[+-]\d+(\.\d+)?$/.test(s)) {
+ return { relative: true, value: Number(s) };
+ }
+ if (/^\d+(\.\d+)?$/.test(s)) {
+ return { relative: false, value: Number(s) };
+ }
+ return null;
+ }
+
+ function safeGet(obj, prop) {
+ // get returns undefined for unknown properties
+ return obj.get(prop);
+ }
+
+ function safeSet(obj, patch) {
+ // patch is an object of property: value
+ try {
+ obj.set(patch);
+ return true;
+ } catch (e) {
+ return false;
+ }
+ }
+
+ // ---------- Argument parser ----------
+ function parseArgs(msg) {
+ // split on -- (case-insensitive)
+ const pieces = msg.content.split(/\s+--/).slice(1);
+ const opts = {
+ convertType: null, // 'window' | 'door' | null
+ convertAll: false,
+ attrs: {} // key (lowercase) -> raw string value
+ };
+
+ for (let raw of pieces) {
+ const part = raw.trim();
+ if (!part) continue;
+ // first token up to whitespace
+ const firstToken = part.split(/\s+/)[0];
+ const keyVal = firstToken.split('|'); // supports --name|value (value may be empty string)
+ const key = toLowerKey(keyVal[0]);
+
+ // Conversion flags
+ if (key === 'convertwindow') {
+ opts.convertType = 'window';
+ continue;
+ }
+ if (key === 'convertdoor') {
+ opts.convertType = 'door';
+ continue;
+ }
+ if (key === 'all') {
+ opts.convertAll = true;
+ continue;
+ }
+
+ // Attribute flag: key|value (value may contain pipes if user included them, so combine the rest)
+ // Reconstruct the full argument (including any remaining chars after the first token)
+ const afterFirstToken = part.substring(firstToken.length).trim();
+ let value = null;
+ if (keyVal.length >= 2) {
+ // If user put --attr|value directly in firstToken
+ value = keyVal.slice(1).join('|');
+ } else if (afterFirstToken.startsWith('|')) {
+ value = afterFirstToken.substring(1).trim();
+ } else {
+ // No pipe provided; treat as flag with empty string
+ value = '';
+ }
+
+ opts.attrs[key] = value;
+ }
+
+ return opts;
+ }
+
+ // ---------- Path endpoint code (keeps your existing logic, fixed bounding center) ----------
+ function getPathEndpoints(obj) {
+ const type = obj.get('_type');
+
+ // --- Classic Path (legacy engine) ---
+ if (type === 'path') {
+ const raw = JSON.parse(obj.get('_path') || '[]');
+ if (raw.length < 2) return null;
+
+ const coords = raw
+ .filter(p => p && p.length >= 3)
+ .map(p => ({ x: p[1], y: p[2] }));
+ if (coords.length < 2) return null;
+
+ const left = obj.get('left') || 0;
+ const top = obj.get('top') || 0;
+ const width = obj.get('width') || 0;
+ const height = obj.get('height') || 0;
+
+ // Compute bounding box of the path in local coords
+ const minX = Math.min(...coords.map(p => p.x));
+ const maxX = Math.max(...coords.map(p => p.x));
+ const minY = Math.min(...coords.map(p => p.y));
+ const maxY = Math.max(...coords.map(p => p.y));
+ const boxCenter = { x: (minX + maxX) / 2, y: (minY + maxY) / 2 };
+
+ // Convert each point to world space relative to bounding box center
+ // left/top is center of the object's bounding box in world coords, so:
+ const start = {
+ x: left - width / 2 + (coords[0].x - boxCenter.x + width / 2),
+ y: top - height / 2 + (coords[0].y - boxCenter.y + height / 2)
+ };
+ const end = {
+ x: left - width / 2 + (coords[1].x - boxCenter.x + width / 2),
+ y: top - height / 2 + (coords[1].y - boxCenter.y + height / 2)
+ };
+
+ return { start, end };
+ }
+
+ // --- PathV2 (latest engine) ---
+ if (type === 'pathv2') {
+ const pts = JSON.parse(obj.get('points') || '[]');
+ if (pts.length !== 2) return null;
+
+ const [p0, p1] = pts;
+ const cx = obj.get('x') || 0;
+ const cy = obj.get('y') || 0;
+
+ const minX = Math.min(p0[0], p1[0]);
+ const maxX = Math.max(p0[0], p1[0]);
+ const minY = Math.min(p0[1], p1[1]);
+ const maxY = Math.max(p0[1], p1[1]);
+ const boxCenter = { x: (minX + maxX) / 2, y: (minY + maxY) / 2 };
+
+ const start = { x: cx + (p0[0] - boxCenter.x), y: cy + (p0[1] - boxCenter.y) };
+ const end = { x: cx + (p1[0] - boxCenter.x), y: cy + (p1[1] - boxCenter.y) };
+
+ return { start, end };
+ }
+
+ return null;
+ }
+
+// ---------- Create portal from endpoints (path -> window/door) ----------
+function createPortalFromEndpoints(type, color, pageid, start, end) {
+ const midX = (start.x + end.x) / 2;
+ const midY = (start.y + end.y) / 2;
+
+ // Handles must be in the portal object's local coordinate space.
+ // Because we set object.y = -midY (Roll20's inverted axis for doors/windows),
+ // we must also invert the handle Y values so the rendered endpoints keep their original slope.
+ const handle0 = {
+ x: start.x - midX,
+ y: -(start.y - midY)
+ };
+ const handle1 = {
+ x: end.x - midX,
+ y: -(end.y - midY)
+ };
+
+ return createObj(type, {
+ pageid,
+ color,
+ x: midX,
+ y: -midY,
+ path: { handle0, handle1 },
+ isOpen: false,
+ isLocked: false,
+ isShuttered: false,
+ layer: 'walls'
+ });
+}
+
+ // ---------- Create portal from an existing portal (window ↔ door conversion) ----------
+ function createPortalFromExisting(existingObj, targetType, overrideAttrs) {
+ // existingObj is a window or door
+ const pageid = existingObj.get('_pageid');
+ const existingType = existingObj.get('_type');
+
+ // copy baseline values
+const color =
+ resolveColor(overrideAttrs && overrideAttrs.color, targetType) ||
+ normalizeColor(existingObj.get('color')) ||
+ (targetType === 'door' ? DEFAULT_COLORS.door : DEFAULT_COLORS.window);
+
+ // path / x / y are shared; they are already in door/window coordinate space, so copy directly
+ const path = existingObj.get('path');
+ const x = (overrideAttrs && overrideAttrs.x !== undefined) ? Number(overrideAttrs.x) : existingObj.get('x') || 0;
+ const y = (overrideAttrs && overrideAttrs.y !== undefined) ? Number(overrideAttrs.y) : existingObj.get('y') || 0;
+
+ // build base props for target object, using defaults where necessary
+ const base = { pageid, color, x, y, path, layer: 'walls' };
+
+ if (targetType === 'window') {
+ base.isOpen = existingObj.get('isOpen') !== undefined ? existingObj.get('isOpen') : WINDOW_DEFAULTS.isOpen;
+ base.isLocked = existingObj.get('isLocked') !== undefined ? existingObj.get('isLocked') : WINDOW_DEFAULTS.isLocked;
+ base.isLocked = existingObj.get('isShuttered') !== undefined ? existingObj.get('isShuttered') : WINDOW_DEFAULTS.isShuttered;
+ } else if (targetType === 'door') {
+ base.isOpen = existingObj.get('isOpen') !== undefined ? existingObj.get('isOpen') : DOOR_DEFAULTS.isOpen;
+ base.isLocked = existingObj.get('isLocked') !== undefined ? existingObj.get('isLocked') : DOOR_DEFAULTS.isLocked;
+ base.isSecret = existingObj.get('isSecret') !== undefined ? existingObj.get('isSecret') : DOOR_DEFAULTS.isSecret;
+ }
+
+ // Create new object
+ const created = createObj(targetType, base);
+ return created;
+ }
+
+ // ---------- Attribute application ----------
+// ---------- Attribute application ----------
+function applyAttributesToPortal(obj, attrs) {
+ if (!attrs || Object.keys(attrs).length === 0) return;
+
+ const objType = obj.get('_type');
+ const allowed = (objType === 'window') ? WINDOW_PROPS : DOOR_PROPS;
+
+ const patch = {};
+
+ for (const rawKey of Object.keys(attrs)) {
+ const key = toLowerKey(rawKey);
+ if (!allowed.includes(key)) continue;
+
+ const rawVal = attrs[rawKey];
+
+ // ---------- Boolean attributes ----------
+ if (['isopen','islocked','issecret','isshuttered'].includes(key)) {
+
+ // map lowercase to actual Roll20 property names
+ const propMap = {
+ isopen: 'isOpen',
+ islocked: 'isLocked',
+ isshuttered: 'isShuttered',
+ issecret: 'isSecret'
+ };
+ const trueKey = propMap[key];
+ if (!trueKey) continue;
+
+ const parsed = parseBoolToken(rawVal);
+ if (!parsed) continue;
+
+ if (parsed.mode === 'flip') {
+ const cur = !!obj.get(trueKey);
+ patch[trueKey] = !cur;
+
+ } else if (parsed.mode === 'set') {
+ patch[trueKey] = !!parsed.value;
+ }
+
+ continue;
+ }
+
+ // ---------- Color ----------
+if (key === 'color') {
+ const objType = obj.get('_type');
+ const resolved = resolveColor(rawVal, objType);
+ if (resolved) patch.color = resolved;
+ continue;
+}
+
+
+ // ---------- x / y numeric ----------
+// ---------- x / y numeric ----------
+if (key === 'x' || key === 'y') {
+ const parsed = parseNumberToken(rawVal);
+ if (!parsed) continue;
+ const cur = Number(obj.get(key) || 0);
+
+ if (key === 'x') {
+ // X behavior unchanged: relative adds, absolute sets directly
+ patch.x = parsed.relative ? cur + parsed.value : parsed.value;
+ } else {
+ // Y: interpret commands in "user" coordinates (positive = down).
+ // Internally Roll20 doors/windows use an inverted Y axis,
+ // so absolute user value -> internal negative.
+ // For relative (+N) the user expects to move *down* by N,
+ // which means internal y should decrease by N (subtract).
+ if (parsed.relative) {
+ patch.y = cur - parsed.value;
+ } else {
+ patch.y = -parsed.value;
+ }
+ }
+ continue;
+}
+
+ }
+
+ if (Object.keys(patch).length > 0) safeSet(obj, patch);
+}
+
+ // ---------- Main conversion logic for a single object ----------
+ function processSingleObject(obj, opts, templateObjForConvertAll) {
+ // obj may be path/pathv2/window/door
+ const type = obj.get('_type');
+
+ // Helper to apply attributes to an object (window/door)
+ const applyAttrsTo = (targetObj) => {
+ applyAttributesToPortal(targetObj, opts.attrs);
+ };
+
+ // If obj is a path or pathv2
+ if (type === 'path' || type === 'pathv2') {
+ // need explicit conversion flag to affect a path
+ if (!opts.convertType) return { converted: 0, skipped: 0 };
+
+ const endpoints = getPathEndpoints(obj);
+ if (!endpoints) return { converted: 0, skipped: 1 };
+
+ // use provided color override if any, otherwise use stroke color of path
+ const strokeColor = normalizeColor(obj.get('stroke'));
+let color =
+ resolveColor(opts.attrs.color, opts.convertType) ||
+ normalizeColor(obj.get('color')) ||
+ strokeColor ||
+ (opts.convertType === 'door' ? DEFAULT_COLORS.door : DEFAULT_COLORS.window);
+
+ const pageid = obj.get('_pageid');
+ const created = createPortalFromEndpoints(opts.convertType, color, pageid, endpoints.start, endpoints.end);
+
+ // apply attributes (attrs map may contain color, x, y, booleans)
+ applyAttrsTo(created);
+
+ // remove original
+ obj.remove();
+ return { converted: 1, skipped: 0 };
+ }
+
+ // If obj is window or door
+ if (type === 'window' || type === 'door') {
+ // If convertType specified and different -> create new of that type, copy/derive attributes
+ if (opts.convertType && opts.convertType !== type) {
+ // Create new portal using existing path/x/y/color, but allow overrides in attrs
+ const overrideAttrs = {};
+ if (opts.attrs.color !== undefined) overrideAttrs.color = opts.attrs.color;
+ if (opts.attrs.x !== undefined) overrideAttrs.x = opts.attrs.x;
+ if (opts.attrs.y !== undefined) overrideAttrs.y = opts.attrs.y;
+
+ const newObj = createPortalFromExisting(obj, opts.convertType, overrideAttrs);
+
+ // Apply remaining attributes to newObj
+ applyAttrsTo(newObj);
+
+ // Remove old
+ obj.remove();
+ return { converted: 1, skipped: 0 };
+ } else {
+ // No conversion requested or converting to same type: simply apply attributes to existing object
+ applyAttrsTo(obj);
+ return { converted: 1, skipped: 0 };
+ }
+ }
+
+ // other object types: do nothing
+ return { converted: 0, skipped: 0 };
+ }
+
+ // ---------- convertAll resolution ----------
+ function findCandidatesForConvertAll(templateObj) {
+ const pageid = templateObj.get('_pageid');
+ const ttype = templateObj.get('_type');
+
+ if (ttype === 'path' || ttype === 'pathv2') {
+ const stroke = normalizeColor(templateObj.get('stroke'));
+ const barrier = templateObj.get('barrierType') || 'wall';
+ return findObjs({ _pageid: pageid }).filter(o =>
+ (o.get('_type') === 'path' || o.get('_type') === 'pathv2') &&
+ normalizeColor(o.get('stroke')) === stroke &&
+ (o.get('barrierType') || 'wall') === barrier
+ );
+ }
+
+ if (ttype === 'door') {
+ const color = normalizeColor(templateObj.get('color'));
+ return findObjs({ _pageid: pageid }).filter(o =>
+ o.get('_type') === 'door' && normalizeColor(o.get('color')) === color
+ );
+ }
+
+ if (ttype === 'window') {
+ const color = normalizeColor(templateObj.get('color'));
+ return findObjs({ _pageid: pageid }).filter(o =>
+ o.get('_type') === 'window' && normalizeColor(o.get('color')) === color
+ );
+ }
+
+ return [];
+ }
+
+ // ---------- Top-level chat handler ----------
+ on('chat:message', (msg) => {
+ if (msg.type !== 'api') return;
+ if (!msg.content.toLowerCase().startsWith(CMD)) return;
+
+ const opts = parseArgs(msg);
+ // opts: convertType ('window'|'door' or null), convertAll boolean, attrs map
+
+ // No output mode (Option C): we will not send success messages.
+ // Basic validation: there must be at least one selected object for non-convertall usage.
+ const selected = msg.selected || [];
+
+ if (opts.convertAll) {
+ // convertAll requires a template object to determine matching criteria
+ if (!selected || selected.length === 0) {
+ return;
+ }
+ // operate on each selected as template (if multiple selected templates, handle each)
+ for (const sel of selected) {
+ const templateObj = getObj(sel._type, sel._id);
+ if (!templateObj) continue;
+ const candidates = findCandidatesForConvertAll(templateObj);
+ for (const c of candidates) {
+ try {
+ processSingleObject(c, opts, templateObj);
+ } catch (e) {
+ // silent fail per Option C
+ }
+ }
+ }
+ return;
+ }
+
+ // Non-convertAll: operate on each selected object individually
+ if (!selected || selected.length === 0) {
+ return;
+ }
+
+ for (const sel of selected) {
+ const obj = getObj(sel._type, sel._id);
+ if (!obj) continue;
+ try {
+ processSingleObject(obj, opts, null);
+ } catch (e) {
+ // silent per Option C
+ }
+ }
+ });
+});
+
+{ try { throw new Error(''); } catch (e) { API_Meta.Portal.lineCount = (parseInt(e.stack.split(/\n/)[1].replace(/^.*:(\d+):.*$/, '$1'), 10) - API_Meta.Portal.offset); } }
diff --git a/Portal/Portal.js b/Portal/Portal.js
new file mode 100644
index 000000000..c0f7072d5
--- /dev/null
+++ b/Portal/Portal.js
@@ -0,0 +1,829 @@
+var API_Meta = API_Meta || {};
+API_Meta.Portal = {
+ offset: Number.MAX_SAFE_INTEGER,
+ lineCount: -1
+}; {
+ try {
+ throw new Error('');
+ } catch (e) {
+ API_Meta.Portal.offset = (parseInt(e.stack.split(/\n/)[1].replace(/^.*:(\d+):.*$/, '$1'), 10) - (4));
+ }
+}
+//Portal - A sccript to control and convert doors and windows.
+on('ready', () => {
+
+ const version = '1.0.0'; //version number set here
+ log('-=> Portal v' + version + ' is loaded. Command !portal creates chat menu to convert and control doors and windows.');
+
+
+
+ const CMD = '!portal';
+let testthingie = "werwers"
+
+ const DEFAULT_COLORS = {
+ door: '#ff9900',
+ window: '#00bfff'
+ };
+
+ const WINDOW_PROPS = ['color','x','y','isopen','islocked', 'isshuttered'];
+ const DOOR_PROPS = ['color','x','y','isopen','islocked','issecret'];
+
+ const DOOR_DEFAULTS = { color: DEFAULT_COLORS.door, x: 0, y: 0, isOpen: false, isLocked: false, isSecret: false };
+ const WINDOW_DEFAULTS = { color: DEFAULT_COLORS.window, x: 0, y: 0, isOpen: false, isLocked: false , isShuttered: false };
+
+ // ---------- Utilities ----------
+
+
+// ############################################################
+// IF YOU NEED TO DIABLE THE HELP SYSTEM, MAKE THIS FALSE
+// ############################################################
+const PORTAL_HELP_ENABLED = true; //MAKE FALSE FO PRODUCTIONWIZARD
+
+const PORTAL_HELP_NAME = "Help: Portal";
+const PORTAL_HELP_AVATAR = "https://files.d20.io/images/442783616/hDkduTWDpcVEKomHnbb6AQ/max.png?45596439";
+
+const PORTAL_HELP_TEXT = `
+Portal Script Help
+
+This script manages doors and windows (“portals”), allowing you to:
+
+- Create portals on the map
+- Lock / unlock them
+- Toggle open/closed state
+- Shutter / unshutter windows
+- Hide / unhide doors
+- Convert Doors and Windows from paths or pathv2 objects
+- Bulk-select and modify portal Properties
+
+
+Base Command: !portal
+
+Conversion Commands
+
+ --convertwindow — Convert selected doors or paths into windows.
+ --convertdoor — Convert selected windows or paths into doors.
+ --convertall — Apply the same conversion to all similar objects:
+
+ - Doors → all doors of same color
+ - Windows → all windows of same color
+ - Paths → all paths matching color and barrierType
+
+
+
+
+Attribute Commands
+Format: --attributeName|value
+
+Values are case-insensitive. Booleans accept:
+
+ - true: true, yes, on
+ - false: false, no, off
+ - flip: toggles true/false
+
+
+Common Door/Window Attributes
+
+ --isLocked|true/false/flip
+ --isOpen|true/false/flip
+ --isSecret|true/false/flip
+ --isShuttered|true/false/flip
+ --color|#rrggbb
+ --color|default — sets selected doors or windows to Roll20 defaults
+ --key|string
+
+
+Position Attributes
+Use + or − prefixes for relative moves:
+
+ --x|100 — set position
+ --x|+20 — move right 20 units
+ --x|-20 — move left 20 units
+ --y|100 — set position
+ --y|+10 — move down 10 units
+
+Note: All Y and Y values are figured with the top left corned of the map being (0,0), and positive values increasing toward the lower right, to conform with how graphics are handled.
+
+Path Handling
+
+ - Only paths with exactly two endpoints are converted.
+ - Paths with more points are skipped and noted in chat.
+ - Position is taken from the path endpoints.
+
+
+General Rules
+
+ - All commands are case-insensitive.
+ - All provided attributes apply to every selected object.
+ - Missing attributes (e.g.,
isSecret on windows) are ignored.
+
+
+Examples
+
+ !portal --convertwindow
+ !portal --isLocked|true
+ !portal --isLocked|flip --isOpen|false
+ !portal --x|+20 --y|-10
+ !portal --convertwindow --color|#FF00FF --isLocked|true
+
+`;
+
+// === HELP COMMAND HANDLER ===
+const handleHelp = (msg) => {
+ if (!PORTAL_HELP_ENABLED) return; // <---- NEW DISABLER
+
+ if (msg.type !== "api") return;
+
+ const args = msg.content.split(/\s+--/);
+ if (!args[1] || !args[1].toLowerCase().startsWith("help")) return;
+
+ // Find existing handout
+ let handout = findObjs({
+ _type: "handout",
+ name: PORTAL_HELP_NAME
+ })[0];
+
+ // Create if missing
+ if (!handout) {
+ handout = createObj("handout", {
+ name: PORTAL_HELP_NAME,
+ archived: false
+ });
+
+ // Set avatar
+ handout.set("avatar", PORTAL_HELP_AVATAR);
+ }
+
+ // Always overwrite contents
+ handout.set("notes", PORTAL_HELP_TEXT);
+
+ // Create GM whisper with styled box
+ const link = `http://journal.roll20.net/handout/${handout.get("_id")}`;
+
+ const box = `
+`.trim().replace(/\r?\n/g, '');
+
+ sendChat("Portal", `/w gm ${box}`);
+};
+
+// Register help handler
+on("chat:message", (msg) => {
+ if (!PORTAL_HELP_ENABLED) return; // <---- NEW DISABLER
+ handleHelp(msg);
+});
+
+function resolveColor(rawColorValue, targetType) {
+ if (!rawColorValue) return null;
+ const v = String(rawColorValue).trim().toLowerCase();
+
+ if (v === 'default') {
+ return (targetType === 'door')
+ ? DEFAULT_COLORS.door
+ : DEFAULT_COLORS.window;
+ }
+
+ return normalizeColor(rawColorValue);
+}
+
+
+// ############################################################
+// END HELP HANDOUT CREATION SELECTION
+// COMMENT THIS SECTION OUT TO DISABLE HELP SYSTEM
+// ############################################################
+
+// --- Chat menu (whispered to GM) ---
+// Insert this block into the script (after your constants / helper defs)
+// --- CHAT MENU + MENU STATE PATCH ---
+// Add these near the other globals (CSS, etc.). It defines menu CSS + helpers + handlers.
+// It intentionally uses a new API command "!portal-mode" to toggle Selected/All to avoid
+// interfering with the main !portal handler.
+
+const CSS = {
+ container: 'position:relative; left:-20px; width:100%; border:1px solid #111; background:#ddd; color:#111; padding:6px; margin:4px; border-radius:6px; font-size:13px; line-height:1.5;',
+ title: 'width:100%; border:none; background:#444; padding:1px; margin-bottom:5px; border-radius:4px; font-size:14px; line-height:1.5; color:#eee; font-weight:bold; text-align:center;',
+ label: 'display:inline-block; font-weight:bold; margin:4px 6px 0 0; width:60px;',
+ button: 'box-shadow:inset 0px 1px 3px 0px #555; background:linear-gradient(to bottom, #333 5%, #555 100%); background-color:#444; border-radius:4px; min-width:10px; text-align:center; border:1px solid #566963; display:inline-block; cursor:pointer; color:#eee; font-size:13px; font-weight:bold; padding:1px 5px; margin:1px; text-decoration:none; text-shadow:0px -1px 0px #2b665e;',
+ active: 'font-weight:bold !important; background:#555;',
+ // new toggle-specific styles (kept inline in CSS object per requirement)
+ toggleWrap: 'text-align:center;margin-bottom:6px;',
+ toggleBtn: 'width:40%; display:inline-block;padding:4px 8px;margin:0 4px;border-radius:4px;cursor:pointer;border:1px solid #333;background:#aaa;color:#fff;font-weight:bold;text-decoration:none;',
+ toggleActive: 'width:40%; background:#444;color:#eee;border:1px solid #566963;' // "Roll20 Pink" look for active state
+};
+
+const buildBtn = (href, label, style, tooltip) =>
+ `${label}`;
+
+// Helper: append --all when menu mode is 'all'
+function buildCmd(base, mode) {
+ if (mode === 'all') {
+ // avoid double-space if already has args
+ return base + ' --all';
+ }
+ return base;
+}
+
+// Ensure persistent state exists
+if (!state.portal_menu) {
+ state.portal_menu = { mode: 'selected' }; // default: selected
+}
+
+// Build the menu HTML (single-block). Always generate based on current state.
+function buildMenuHtml() {
+ const mode = (state.portal_menu && state.portal_menu.mode) ? state.portal_menu.mode : 'selected';
+
+ // toggle button markup
+ const selectedActive = (mode === 'selected') ? CSS.toggleActive : '';
+ const allActive = (mode === 'all') ? CSS.toggleActive : '';
+
+ // helper to build toggle buttons that call the small mode-switcher command
+ const toggleSelected = `Selected Only`;
+ const toggleAll = `All Similar`;
+
+ // Build menu commands using buildCmd(base, mode) so --all will be added when needed
+ const html = `
+
+
Portal
+
+
+ ${toggleSelected}${toggleAll}
+
+
+
+ Convert
+ ${buildBtn(buildCmd('!portal --convertdoor', mode), 'Door', null, 'Convert selected items into doors')}
+ ${buildBtn(buildCmd('!portal --convertwindow', mode), 'Window', null, 'Convert selected items into windows')}
+
+
+
+ Position
+
+ ${buildBtn(buildCmd('!portal --x|?{Enter X value in pixels from top left of page}', mode), 'X', null, 'Set absolute X-position in pixels from top-left')}
+ ${buildBtn(buildCmd('!portal --y|?{Enter Y value in pixels from top left of page}', mode), 'Y', null, 'Set absolute Y-position in pixels from top-left')}
+
+ ${buildBtn(buildCmd('!portal --x|-?{Moving selected items to the left. Enter value in pixels}', mode), '←', null, 'Move left by given number of pixels')}
+ ${buildBtn(buildCmd('!portal --x|+?{Moving selected items to the right. Enter value in pixels}', mode), '→', null, 'Move right by given number of pixels')}
+ ${buildBtn(buildCmd('!portal --y|-?{Moving selected items up. Enter value in pixels}', mode), '↑', null, 'Move up by given number of pixels')}
+ ${buildBtn(buildCmd('!portal --y|+?{Moving selected items down. Enter value in pixels}', mode), '↓', null, 'Move down by given number of pixels')}
+
+
+
+
+ Color
+ ${buildBtn(buildCmd('!portal --color|default', mode), 'Default', null, 'Reset to default portal colors')}
+ ${buildBtn(buildCmd('!portal --color|?{Enter custom color (#rrggbb)}', mode), 'Custom', null, 'Apply a custom color. Must be a hex value starting with #')}
+
+
+
+ Locked
+ ${buildBtn(buildCmd('!portal --isLocked|true', mode), 'Locked', null, 'Set door or window to locked')}
+ ${buildBtn(buildCmd('!portal --isLocked|false', mode), 'Unlocked', null, 'Set door or window to unlocked')}
+ ${buildBtn(buildCmd('!portal --isLocked|flip', mode), 'Toggle', null, 'Toggle locked/unlocked')}
+
+
+
+ Open
+ ${buildBtn(buildCmd('!portal --isOpen|true', mode), 'Open', null, 'Set door or window to open')}
+ ${buildBtn(buildCmd('!portal --isOpen|false', mode), 'Closed', null, 'Set door or window to closed')}
+ ${buildBtn(buildCmd('!portal --isOpen|flip', mode), 'Toggle', null, 'Toggle open/closed')}
+
+
+
+ Doors
+ ${buildBtn(buildCmd('!portal --isSecret|true', mode), 'Hidden', null, 'Set door as secret / hidden')}
+ ${buildBtn(buildCmd('!portal --isSecret|false', mode), 'Visible', null, 'Set door as visible')}
+
+
+
+ Windows
+ ${buildBtn(buildCmd('!portal --isShuttered|true', mode), 'Shuttered', null, 'Set window as shuttered')}
+ ${buildBtn(buildCmd('!portal --isShuttered|false', mode), 'Unshuttered', null, 'Set window as unshuttered')}
+
+
+
+ ${PORTAL_HELP_ENABLED ? `
+
+ Help
+ ${buildBtn('!portal --help', 'Help', null, 'Open the Portal help handout')}
+
` : ``}
+
`.trim();
+return html.replace(/\r?\n/g, '');
+// return html;
+}
+
+
+// Menu requester: exact "!portal" with no args (whisper to GM)
+const handleMenu = (msg) => {
+ if (msg.type !== 'api') return;
+ if (String(msg.content || '').trim().toLowerCase() !== CMD) return;
+
+ const html = buildMenuHtml();
+ sendChat('Portal', `/w gm ${html}`);
+};
+
+// New small handler to update menu mode: "!portal-mode --mode|selected" or "--mode|all"
+const handleMenuMode = (msg) => {
+ if (msg.type !== 'api') return;
+ if (!msg.content.toLowerCase().startsWith('!portal-mode')) return;
+
+ // parse simple --mode|value
+ const pieces = msg.content.split(/\s+--/).slice(1);
+ let newMode = null;
+ for (const raw of pieces) {
+ const firstToken = raw.trim().split(/\s+/)[0] || '';
+ const kv = firstToken.split('|');
+ const key = (kv[0] || '').toLowerCase();
+ const val = kv.slice(1).join('|') || (raw.substring(firstToken.length).trim().startsWith('|') ? raw.substring(firstToken.length).trim().substring(1).trim() : '');
+ if (key === 'mode' && val) {
+ const v = val.toLowerCase();
+ if (v === 'selected' || v === 'all') newMode = v;
+ }
+ }
+
+ if (!newMode) {
+ // invalid usage: re-send menu to show current state
+ sendChat('Portal', `/w gm ${buildMenuHtml()}`);
+ return;
+ }
+
+ // Ensure state object exists and set the mode
+ state.portal_menu = state.portal_menu || {};
+ state.portal_menu.mode = newMode;
+
+ // Re-send the menu so GM sees updated toggles
+ sendChat('Portal', `/w gm ${buildMenuHtml()}`);
+};
+
+// Register both handlers (order doesn't matter)
+on('chat:message', handleMenuMode);
+on('chat:message', handleMenu);
+
+// --- end of chat menu patch ---
+
+
+
+
+ function normalizeColor(c) {
+ if (!c) return null;
+ c = String(c).trim().toLowerCase();
+ if (c.length === 4 && c[0] === '#') {
+ return '#' + c[1] + c[1] + c[2] + c[2] + c[3] + c[3];
+ }
+ return c;
+ }
+
+ function toLowerKey(k) {
+ return String(k || '').trim().toLowerCase();
+ }
+
+ function parseBoolToken(tok) {
+ if (!tok && tok !== '') return null;
+ const t = String(tok).trim().toLowerCase();
+ if (['true','yes','on'].includes(t)) return { mode: 'set', value: true };
+ if (['false','no','off'].includes(t)) return { mode: 'set', value: false };
+ if (t === 'flip') return { mode: 'flip' };
+ return null;
+ }
+
+ function parseNumberToken(tok) {
+ if (tok === undefined || tok === null) return null;
+ const s = String(tok).trim();
+ if (s.length === 0) return null;
+ // relative?
+ if (/^[+-]\d+(\.\d+)?$/.test(s)) {
+ return { relative: true, value: Number(s) };
+ }
+ if (/^\d+(\.\d+)?$/.test(s)) {
+ return { relative: false, value: Number(s) };
+ }
+ return null;
+ }
+
+ function safeGet(obj, prop) {
+ // get returns undefined for unknown properties
+ return obj.get(prop);
+ }
+
+ function safeSet(obj, patch) {
+ // patch is an object of property: value
+ try {
+ obj.set(patch);
+ return true;
+ } catch (e) {
+ return false;
+ }
+ }
+
+ // ---------- Argument parser ----------
+ function parseArgs(msg) {
+ // split on -- (case-insensitive)
+ const pieces = msg.content.split(/\s+--/).slice(1);
+ const opts = {
+ convertType: null, // 'window' | 'door' | null
+ convertAll: false,
+ attrs: {} // key (lowercase) -> raw string value
+ };
+
+ for (let raw of pieces) {
+ const part = raw.trim();
+ if (!part) continue;
+ // first token up to whitespace
+ const firstToken = part.split(/\s+/)[0];
+ const keyVal = firstToken.split('|'); // supports --name|value (value may be empty string)
+ const key = toLowerKey(keyVal[0]);
+
+ // Conversion flags
+ if (key === 'convertwindow') {
+ opts.convertType = 'window';
+ continue;
+ }
+ if (key === 'convertdoor') {
+ opts.convertType = 'door';
+ continue;
+ }
+ if (key === 'all') {
+ opts.convertAll = true;
+ continue;
+ }
+
+ // Attribute flag: key|value (value may contain pipes if user included them, so combine the rest)
+ // Reconstruct the full argument (including any remaining chars after the first token)
+ const afterFirstToken = part.substring(firstToken.length).trim();
+ let value = null;
+ if (keyVal.length >= 2) {
+ // If user put --attr|value directly in firstToken
+ value = keyVal.slice(1).join('|');
+ } else if (afterFirstToken.startsWith('|')) {
+ value = afterFirstToken.substring(1).trim();
+ } else {
+ // No pipe provided; treat as flag with empty string
+ value = '';
+ }
+
+ opts.attrs[key] = value;
+ }
+
+ return opts;
+ }
+
+ // ---------- Path endpoint code (keeps your existing logic, fixed bounding center) ----------
+ function getPathEndpoints(obj) {
+ const type = obj.get('_type');
+
+ // --- Classic Path (legacy engine) ---
+ if (type === 'path') {
+ const raw = JSON.parse(obj.get('_path') || '[]');
+ if (raw.length < 2) return null;
+
+ const coords = raw
+ .filter(p => p && p.length >= 3)
+ .map(p => ({ x: p[1], y: p[2] }));
+ if (coords.length < 2) return null;
+
+ const left = obj.get('left') || 0;
+ const top = obj.get('top') || 0;
+ const width = obj.get('width') || 0;
+ const height = obj.get('height') || 0;
+
+ // Compute bounding box of the path in local coords
+ const minX = Math.min(...coords.map(p => p.x));
+ const maxX = Math.max(...coords.map(p => p.x));
+ const minY = Math.min(...coords.map(p => p.y));
+ const maxY = Math.max(...coords.map(p => p.y));
+ const boxCenter = { x: (minX + maxX) / 2, y: (minY + maxY) / 2 };
+
+ // Convert each point to world space relative to bounding box center
+ // left/top is center of the object's bounding box in world coords, so:
+ const start = {
+ x: left - width / 2 + (coords[0].x - boxCenter.x + width / 2),
+ y: top - height / 2 + (coords[0].y - boxCenter.y + height / 2)
+ };
+ const end = {
+ x: left - width / 2 + (coords[1].x - boxCenter.x + width / 2),
+ y: top - height / 2 + (coords[1].y - boxCenter.y + height / 2)
+ };
+
+ return { start, end };
+ }
+
+ // --- PathV2 (latest engine) ---
+ if (type === 'pathv2') {
+ const pts = JSON.parse(obj.get('points') || '[]');
+ if (pts.length !== 2) return null;
+
+ const [p0, p1] = pts;
+ const cx = obj.get('x') || 0;
+ const cy = obj.get('y') || 0;
+
+ const minX = Math.min(p0[0], p1[0]);
+ const maxX = Math.max(p0[0], p1[0]);
+ const minY = Math.min(p0[1], p1[1]);
+ const maxY = Math.max(p0[1], p1[1]);
+ const boxCenter = { x: (minX + maxX) / 2, y: (minY + maxY) / 2 };
+
+ const start = { x: cx + (p0[0] - boxCenter.x), y: cy + (p0[1] - boxCenter.y) };
+ const end = { x: cx + (p1[0] - boxCenter.x), y: cy + (p1[1] - boxCenter.y) };
+
+ return { start, end };
+ }
+
+ return null;
+ }
+
+// ---------- Create portal from endpoints (path -> window/door) ----------
+function createPortalFromEndpoints(type, color, pageid, start, end) {
+ const midX = (start.x + end.x) / 2;
+ const midY = (start.y + end.y) / 2;
+
+ // Handles must be in the portal object's local coordinate space.
+ // Because we set object.y = -midY (Roll20's inverted axis for doors/windows),
+ // we must also invert the handle Y values so the rendered endpoints keep their original slope.
+ const handle0 = {
+ x: start.x - midX,
+ y: -(start.y - midY)
+ };
+ const handle1 = {
+ x: end.x - midX,
+ y: -(end.y - midY)
+ };
+
+ return createObj(type, {
+ pageid,
+ color,
+ x: midX,
+ y: -midY,
+ path: { handle0, handle1 },
+ isOpen: false,
+ isLocked: false,
+ isShuttered: false,
+ layer: 'walls'
+ });
+}
+
+ // ---------- Create portal from an existing portal (window ↔ door conversion) ----------
+ function createPortalFromExisting(existingObj, targetType, overrideAttrs) {
+ // existingObj is a window or door
+ const pageid = existingObj.get('_pageid');
+ const existingType = existingObj.get('_type');
+
+ // copy baseline values
+const color =
+ resolveColor(overrideAttrs && overrideAttrs.color, targetType) ||
+ normalizeColor(existingObj.get('color')) ||
+ (targetType === 'door' ? DEFAULT_COLORS.door : DEFAULT_COLORS.window);
+
+ // path / x / y are shared; they are already in door/window coordinate space, so copy directly
+ const path = existingObj.get('path');
+ const x = (overrideAttrs && overrideAttrs.x !== undefined) ? Number(overrideAttrs.x) : existingObj.get('x') || 0;
+ const y = (overrideAttrs && overrideAttrs.y !== undefined) ? Number(overrideAttrs.y) : existingObj.get('y') || 0;
+
+ // build base props for target object, using defaults where necessary
+ const base = { pageid, color, x, y, path, layer: 'walls' };
+
+ if (targetType === 'window') {
+ base.isOpen = existingObj.get('isOpen') !== undefined ? existingObj.get('isOpen') : WINDOW_DEFAULTS.isOpen;
+ base.isLocked = existingObj.get('isLocked') !== undefined ? existingObj.get('isLocked') : WINDOW_DEFAULTS.isLocked;
+ base.isLocked = existingObj.get('isShuttered') !== undefined ? existingObj.get('isShuttered') : WINDOW_DEFAULTS.isShuttered;
+ } else if (targetType === 'door') {
+ base.isOpen = existingObj.get('isOpen') !== undefined ? existingObj.get('isOpen') : DOOR_DEFAULTS.isOpen;
+ base.isLocked = existingObj.get('isLocked') !== undefined ? existingObj.get('isLocked') : DOOR_DEFAULTS.isLocked;
+ base.isSecret = existingObj.get('isSecret') !== undefined ? existingObj.get('isSecret') : DOOR_DEFAULTS.isSecret;
+ }
+
+ // Create new object
+ const created = createObj(targetType, base);
+ return created;
+ }
+
+ // ---------- Attribute application ----------
+// ---------- Attribute application ----------
+function applyAttributesToPortal(obj, attrs) {
+ if (!attrs || Object.keys(attrs).length === 0) return;
+
+ const objType = obj.get('_type');
+ const allowed = (objType === 'window') ? WINDOW_PROPS : DOOR_PROPS;
+
+ const patch = {};
+
+ for (const rawKey of Object.keys(attrs)) {
+ const key = toLowerKey(rawKey);
+ if (!allowed.includes(key)) continue;
+
+ const rawVal = attrs[rawKey];
+
+ // ---------- Boolean attributes ----------
+ if (['isopen','islocked','issecret','isshuttered'].includes(key)) {
+
+ // map lowercase to actual Roll20 property names
+ const propMap = {
+ isopen: 'isOpen',
+ islocked: 'isLocked',
+ isshuttered: 'isShuttered',
+ issecret: 'isSecret'
+ };
+ const trueKey = propMap[key];
+ if (!trueKey) continue;
+
+ const parsed = parseBoolToken(rawVal);
+ if (!parsed) continue;
+
+ if (parsed.mode === 'flip') {
+ const cur = !!obj.get(trueKey);
+ patch[trueKey] = !cur;
+
+ } else if (parsed.mode === 'set') {
+ patch[trueKey] = !!parsed.value;
+ }
+
+ continue;
+ }
+
+ // ---------- Color ----------
+if (key === 'color') {
+ const objType = obj.get('_type');
+ const resolved = resolveColor(rawVal, objType);
+ if (resolved) patch.color = resolved;
+ continue;
+}
+
+
+ // ---------- x / y numeric ----------
+// ---------- x / y numeric ----------
+if (key === 'x' || key === 'y') {
+ const parsed = parseNumberToken(rawVal);
+ if (!parsed) continue;
+ const cur = Number(obj.get(key) || 0);
+
+ if (key === 'x') {
+ // X behavior unchanged: relative adds, absolute sets directly
+ patch.x = parsed.relative ? cur + parsed.value : parsed.value;
+ } else {
+ // Y: interpret commands in "user" coordinates (positive = down).
+ // Internally Roll20 doors/windows use an inverted Y axis,
+ // so absolute user value -> internal negative.
+ // For relative (+N) the user expects to move *down* by N,
+ // which means internal y should decrease by N (subtract).
+ if (parsed.relative) {
+ patch.y = cur - parsed.value;
+ } else {
+ patch.y = -parsed.value;
+ }
+ }
+ continue;
+}
+
+ }
+
+ if (Object.keys(patch).length > 0) safeSet(obj, patch);
+}
+
+ // ---------- Main conversion logic for a single object ----------
+ function processSingleObject(obj, opts, templateObjForConvertAll) {
+ // obj may be path/pathv2/window/door
+ const type = obj.get('_type');
+
+ // Helper to apply attributes to an object (window/door)
+ const applyAttrsTo = (targetObj) => {
+ applyAttributesToPortal(targetObj, opts.attrs);
+ };
+
+ // If obj is a path or pathv2
+ if (type === 'path' || type === 'pathv2') {
+ // need explicit conversion flag to affect a path
+ if (!opts.convertType) return { converted: 0, skipped: 0 };
+
+ const endpoints = getPathEndpoints(obj);
+ if (!endpoints) return { converted: 0, skipped: 1 };
+
+ // use provided color override if any, otherwise use stroke color of path
+ const strokeColor = normalizeColor(obj.get('stroke'));
+let color =
+ resolveColor(opts.attrs.color, opts.convertType) ||
+ normalizeColor(obj.get('color')) ||
+ strokeColor ||
+ (opts.convertType === 'door' ? DEFAULT_COLORS.door : DEFAULT_COLORS.window);
+
+ const pageid = obj.get('_pageid');
+ const created = createPortalFromEndpoints(opts.convertType, color, pageid, endpoints.start, endpoints.end);
+
+ // apply attributes (attrs map may contain color, x, y, booleans)
+ applyAttrsTo(created);
+
+ // remove original
+ obj.remove();
+ return { converted: 1, skipped: 0 };
+ }
+
+ // If obj is window or door
+ if (type === 'window' || type === 'door') {
+ // If convertType specified and different -> create new of that type, copy/derive attributes
+ if (opts.convertType && opts.convertType !== type) {
+ // Create new portal using existing path/x/y/color, but allow overrides in attrs
+ const overrideAttrs = {};
+ if (opts.attrs.color !== undefined) overrideAttrs.color = opts.attrs.color;
+ if (opts.attrs.x !== undefined) overrideAttrs.x = opts.attrs.x;
+ if (opts.attrs.y !== undefined) overrideAttrs.y = opts.attrs.y;
+
+ const newObj = createPortalFromExisting(obj, opts.convertType, overrideAttrs);
+
+ // Apply remaining attributes to newObj
+ applyAttrsTo(newObj);
+
+ // Remove old
+ obj.remove();
+ return { converted: 1, skipped: 0 };
+ } else {
+ // No conversion requested or converting to same type: simply apply attributes to existing object
+ applyAttrsTo(obj);
+ return { converted: 1, skipped: 0 };
+ }
+ }
+
+ // other object types: do nothing
+ return { converted: 0, skipped: 0 };
+ }
+
+ // ---------- convertAll resolution ----------
+ function findCandidatesForConvertAll(templateObj) {
+ const pageid = templateObj.get('_pageid');
+ const ttype = templateObj.get('_type');
+
+ if (ttype === 'path' || ttype === 'pathv2') {
+ const stroke = normalizeColor(templateObj.get('stroke'));
+ const barrier = templateObj.get('barrierType') || 'wall';
+ return findObjs({ _pageid: pageid }).filter(o =>
+ (o.get('_type') === 'path' || o.get('_type') === 'pathv2') &&
+ normalizeColor(o.get('stroke')) === stroke &&
+ (o.get('barrierType') || 'wall') === barrier
+ );
+ }
+
+ if (ttype === 'door') {
+ const color = normalizeColor(templateObj.get('color'));
+ return findObjs({ _pageid: pageid }).filter(o =>
+ o.get('_type') === 'door' && normalizeColor(o.get('color')) === color
+ );
+ }
+
+ if (ttype === 'window') {
+ const color = normalizeColor(templateObj.get('color'));
+ return findObjs({ _pageid: pageid }).filter(o =>
+ o.get('_type') === 'window' && normalizeColor(o.get('color')) === color
+ );
+ }
+
+ return [];
+ }
+
+ // ---------- Top-level chat handler ----------
+ on('chat:message', (msg) => {
+ if (msg.type !== 'api') return;
+ if (!msg.content.toLowerCase().startsWith(CMD)) return;
+
+ const opts = parseArgs(msg);
+ // opts: convertType ('window'|'door' or null), convertAll boolean, attrs map
+
+ // No output mode (Option C): we will not send success messages.
+ // Basic validation: there must be at least one selected object for non-convertall usage.
+ const selected = msg.selected || [];
+
+ if (opts.convertAll) {
+ // convertAll requires a template object to determine matching criteria
+ if (!selected || selected.length === 0) {
+ return;
+ }
+ // operate on each selected as template (if multiple selected templates, handle each)
+ for (const sel of selected) {
+ const templateObj = getObj(sel._type, sel._id);
+ if (!templateObj) continue;
+ const candidates = findCandidatesForConvertAll(templateObj);
+ for (const c of candidates) {
+ try {
+ processSingleObject(c, opts, templateObj);
+ } catch (e) {
+ // silent fail per Option C
+ }
+ }
+ }
+ return;
+ }
+
+ // Non-convertAll: operate on each selected object individually
+ if (!selected || selected.length === 0) {
+ return;
+ }
+
+ for (const sel of selected) {
+ const obj = getObj(sel._type, sel._id);
+ if (!obj) continue;
+ try {
+ processSingleObject(obj, opts, null);
+ } catch (e) {
+ // silent per Option C
+ }
+ }
+ });
+});
+
+{ try { throw new Error(''); } catch (e) { API_Meta.Portal.lineCount = (parseInt(e.stack.split(/\n/)[1].replace(/^.*:(\d+):.*$/, '$1'), 10) - API_Meta.Portal.offset); } }
diff --git a/Portal/readme.md b/Portal/readme.md
new file mode 100644
index 000000000..0721ddab4
--- /dev/null
+++ b/Portal/readme.md
@@ -0,0 +1,74 @@
+# Portal Script Help
+
+This script manages doors and windows (“portals”), allowing you to:
+
+- Create portals on the map
+- Lock / unlock them
+- Toggle open/closed state
+- Shutter / unshutter windows
+- Hide / unhide doors
+- Convert Doors and Windows from paths or pathv2 objects
+- Bulk-select and modify portal properties
+
+**Base Command:** `!portal`
+
+## Conversion Commands
+
+- `--convertwindow` — Convert selected doors or paths into windows.
+- `--convertdoor` — Convert selected windows or paths into doors.
+- `--convertall` — Apply the same conversion to all similar objects:
+ - Doors → all doors of same color
+ - Windows → all windows of same color
+ - Paths → all paths matching color and barrierType
+
+## Attribute Commands
+
+Format: `--attributeName|value`
+
+Values are case-insensitive. Booleans accept:
+
+- **true:** true, yes, on
+- **false:** false, no, off
+- **flip:** toggles true/false
+
+### Common Door/Window Attributes
+
+- `--isLocked|true/false/flip`
+- `--isOpen|true/false/flip`
+- `--isSecret|true/false/flip`
+- `--isShuttered|true/false/flip`
+- `--color|#rrggbb`
+- `--color|default` — sets selected doors or windows to Roll20 defaults
+- `--key|string`
+
+### Position Attributes
+
+Use `+` or `−` prefixes for relative moves:
+
+- `--x|100` — set position
+- `--x|+20` — move right 20 units
+- `--x|-20` — move left 20 units
+- `--y|100` — set position
+- `--y|+10` — move down 10 units
+
+**Note:** All X and Y values use the top-left corner of the map as (0,0), with positive values increasing toward the lower right, matching Roll20’s graphic system.
+
+## Path Handling
+
+- Only paths with exactly two endpoints are converted.
+- Paths with more points are skipped and noted in chat.
+- Position is taken from the path endpoints.
+
+## General Rules
+
+- All commands are case-insensitive.
+- All provided attributes apply to every selected object.
+- Missing attributes (e.g., `isSecret` on windows) are ignored.
+
+## Examples
+
+- `!portal --convertwindow`
+- `!portal --isLocked|true`
+- `!portal --isLocked|flip --isOpen|false`
+- `!portal --x|+20 --y|-10`
+- `!portal --convertwindow --color|#FF00FF --isLocked|true`
diff --git a/Portal/script.json b/Portal/script.json
new file mode 100644
index 000000000..fbe9aba13
--- /dev/null
+++ b/Portal/script.json
@@ -0,0 +1,15 @@
+{
+ "name": "Portal",
+ "script": "Portal.js",
+ "version": "1.0.0",
+ "description": "# Portal\n\n**Portal** is a script for converting older dynamic lighting maps to modern doors and windows.\nIt also allows for bulk setting of door and window properties",
+ "authors": "keithcurtis",
+ "roll20userid": "162065",
+ "dependencies": [],
+ "modifies": {
+ "door": "read, write",
+ "window": "read, write"
+ },
+ "conflicts": [],
+ "previousversions": ["1.0.0"]
+}