From 616332caa399c378035c1cc95029635bca3283a4 Mon Sep 17 00:00:00 2001
From: Kenan Millet
Date: Mon, 15 Jun 2026 07:59:40 -0400
Subject: [PATCH 01/28] Mirror v1.0.0: flat property syncing between tokens
---
Mirror/Mirror.js | 320 +++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 320 insertions(+)
create mode 100644 Mirror/Mirror.js
diff --git a/Mirror/Mirror.js b/Mirror/Mirror.js
new file mode 100644
index 0000000000..649f4e8094
--- /dev/null
+++ b/Mirror/Mirror.js
@@ -0,0 +1,320 @@
+// =============================================================================
+// Mirror v1.0.0
+// Last Updated: 2026-06-15
+// Author: Kenan Millet
+//
+// Description:
+// Flat property syncing between tokens. No transforms, no offsets -- when a
+// property changes on one token, the same value is copied to linked tokens.
+// Supports unidirectional (link) and bidirectional ring (chain) modes.
+//
+// Dependencies: none
+//
+// Commands:
+// !mirror link [props] [ids...] Link selected/listed tokens (unidirectional)
+// !mirror unlink [props] [ids...] Remove link (or specific properties)
+// !mirror chain [props] [ids...] Bidirectional ring link
+// !mirror unchain [props] [ids...] Remove chain (or specific properties)
+// !mirror status Show mirror state for selected tokens
+// !mirror --help Command reference
+// =============================================================================
+
+/* global on, sendChat, getObj, findObjs, playerIsGM, log, state */
+
+var Mirror = Mirror || (() => {
+ 'use strict';
+
+ const SCRIPT_NAME = 'Mirror';
+ const SCRIPT_VERSION = '1.0.0';
+ const CMD = '!mirror';
+
+ // All syncable graphic properties
+ const ALL_PROPS = [
+ 'left', 'top', 'width', 'height', 'rotation',
+ 'flipv', 'fliph', 'layer',
+ 'bar1_value', 'bar1_max', 'bar2_value', 'bar2_max', 'bar3_value', 'bar3_max',
+ 'aura1_radius', 'aura1_color', 'aura1_square',
+ 'aura2_radius', 'aura2_color', 'aura2_square',
+ 'tint_color', 'statusmarkers', 'name', 'showname',
+ 'light_radius', 'light_dimradius', 'light_angle', 'light_otherplayers',
+ 'light_hassight', 'light_losangle', 'light_multiplier',
+ 'has_bright_light_vision', 'has_night_vision', 'night_vision_distance',
+ 'emits_bright_light', 'bright_light_distance', 'emits_low_light', 'low_light_distance',
+ 'baseOpacity', 'currentSide'
+ ];
+
+ // =========================================================================
+ // Helpers
+ // =========================================================================
+
+ const getPlayerName = (playerid) => {
+ if (!playerid || playerid === 'API') return 'gm';
+ const player = getObj('player', playerid);
+ return player ? player.get('_displayname') : 'gm';
+ };
+
+ const reply = (msg, tag, text) => {
+ const body = text !== undefined ? text : tag;
+ const prefix = text !== undefined ? ' [' + tag + ']' : '';
+ const recipient = getPlayerName(msg.playerid);
+ sendChat(SCRIPT_NAME + prefix, '/w "' + recipient + '" ' + body);
+ };
+
+ const genId = () => Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8);
+
+ const ensureState = () => {
+ if (!state[SCRIPT_NAME]) {
+ state[SCRIPT_NAME] = {
+ // linkId → { props: [...], ids: [...], mode: 'link'|'chain' }
+ links: {}
+ };
+ }
+ };
+
+ // =========================================================================
+ // Link Management
+ // =========================================================================
+
+ const getSelectedIds = (msg) => {
+ return (msg.selected || []).map(function(s) { return s._id; }).filter(Boolean);
+ };
+
+ const parseArgs = (args) => {
+ var props = [];
+ var ids = [];
+ args.forEach(function(arg) {
+ if (ALL_PROPS.indexOf(arg) !== -1) props.push(arg);
+ else if (arg.startsWith('-')) ids.push(arg); // Roll20 IDs start with -
+ });
+ return { props: props, ids: ids };
+ };
+
+ const createLink = (mode, props, ids) => {
+ var s = state[SCRIPT_NAME];
+ var linkId = genId();
+ s.links[linkId] = { props: props, ids: ids, mode: mode };
+ return linkId;
+ };
+
+ const findLinksForToken = (tokenId) => {
+ var s = state[SCRIPT_NAME];
+ var results = [];
+ Object.entries(s.links).forEach(function(entry) {
+ if (entry[1].ids.indexOf(tokenId) !== -1) results.push({ id: entry[0], link: entry[1] });
+ });
+ return results;
+ };
+
+ const removePropsFromLink = (linkId, propsToRemove) => {
+ var s = state[SCRIPT_NAME];
+ var link = s.links[linkId];
+ if (!link) return;
+ if (!propsToRemove || propsToRemove.length === 0) {
+ delete s.links[linkId];
+ } else {
+ link.props = link.props.filter(function(p) { return propsToRemove.indexOf(p) === -1; });
+ if (link.props.length === 0) delete s.links[linkId];
+ }
+ };
+
+ // =========================================================================
+ // Sync Engine
+ // =========================================================================
+
+ var syncing = false;
+
+ const onGraphicChanged = (obj) => {
+ if (syncing) return;
+ var s = state[SCRIPT_NAME];
+ var tokenId = obj.get('id');
+
+ // Find all links this token participates in
+ Object.values(s.links).forEach(function(link) {
+ var idx = link.ids.indexOf(tokenId);
+ if (idx === -1) return;
+
+ // Determine which tokens to update
+ var targets;
+ if (link.mode === 'chain') {
+ // Bidirectional: update all others in the link
+ targets = link.ids.filter(function(id) { return id !== tokenId; });
+ } else {
+ // Unidirectional: only propagate if this is the source (first id)
+ if (idx !== 0) return;
+ targets = link.ids.slice(1);
+ }
+
+ // Build updates from changed properties that are in this link's prop list
+ var updates = {};
+ link.props.forEach(function(prop) {
+ updates[prop] = obj.get(prop);
+ });
+
+ syncing = true;
+ targets.forEach(function(targetId) {
+ var target = getObj('graphic', targetId);
+ if (target) target.set(updates);
+ });
+ syncing = false;
+ });
+ };
+
+ // =========================================================================
+ // Commands
+ // =========================================================================
+
+ const doLink = (msg, args) => {
+ var parsed = parseArgs(args);
+ var ids = parsed.ids.concat(getSelectedIds(msg));
+ // Deduplicate
+ ids = ids.filter(function(id, i) { return ids.indexOf(id) === i; });
+
+ if (ids.length < 2) { reply(msg, 'Error', 'Link requires at least 2 tokens.'); return; }
+ var props = parsed.props.length > 0 ? parsed.props : ALL_PROPS.slice();
+ var linkId = createLink('link', props, ids);
+ reply(msg, 'Link', 'Linked ' + ids.length + ' tokens (' + props.length + ' properties). Source: first selected/listed.');
+ };
+
+ const doUnlink = (msg, args) => {
+ var parsed = parseArgs(args);
+ var ids = parsed.ids.concat(getSelectedIds(msg));
+ ids = ids.filter(function(id, i) { return ids.indexOf(id) === i; });
+
+ if (ids.length === 0) { reply(msg, 'Error', 'Select or specify token(s).'); return; }
+
+ var removed = 0;
+ ids.forEach(function(id) {
+ var links = findLinksForToken(id);
+ links.forEach(function(entry) {
+ if (entry.link.mode === 'link') {
+ removePropsFromLink(entry.id, parsed.props.length > 0 ? parsed.props : null);
+ removed++;
+ }
+ });
+ });
+ reply(msg, 'Unlink', 'Removed ' + removed + ' link(s).');
+ };
+
+ const doChain = (msg, args) => {
+ var parsed = parseArgs(args);
+ var ids = parsed.ids.concat(getSelectedIds(msg));
+ ids = ids.filter(function(id, i) { return ids.indexOf(id) === i; });
+
+ if (ids.length < 2) { reply(msg, 'Error', 'Chain requires at least 2 tokens.'); return; }
+ var props = parsed.props.length > 0 ? parsed.props : ALL_PROPS.slice();
+ var linkId = createLink('chain', props, ids);
+ reply(msg, 'Chain', 'Chain-linked ' + ids.length + ' tokens (' + props.length + ' properties).');
+ };
+
+ const doUnchain = (msg, args) => {
+ var parsed = parseArgs(args);
+ var ids = parsed.ids.concat(getSelectedIds(msg));
+ ids = ids.filter(function(id, i) { return ids.indexOf(id) === i; });
+
+ if (ids.length === 0) { reply(msg, 'Error', 'Select or specify token(s).'); return; }
+
+ var removed = 0;
+ ids.forEach(function(id) {
+ var links = findLinksForToken(id);
+ links.forEach(function(entry) {
+ if (entry.link.mode === 'chain') {
+ removePropsFromLink(entry.id, parsed.props.length > 0 ? parsed.props : null);
+ removed++;
+ }
+ });
+ });
+ reply(msg, 'Unchain', 'Removed ' + removed + ' chain(s).');
+ };
+
+ const doStatus = (msg) => {
+ var ids = getSelectedIds(msg);
+ if (ids.length === 0) { reply(msg, 'Error', 'Select token(s).'); return; }
+
+ var out = '';
+ ids.forEach(function(id) {
+ var obj = getObj('graphic', id);
+ var name = obj ? (obj.get('name') || id) : id;
+ var links = findLinksForToken(id);
+ out += '' + name + ': ';
+ if (links.length === 0) { out += 'no mirror links
'; return; }
+ links.forEach(function(entry) {
+ out += entry.link.mode + ' (' + entry.link.props.length + ' props, ' + entry.link.ids.length + ' tokens)
';
+ });
+ });
+ reply(msg, out);
+ };
+
+ const HELP_TEXT = '' + SCRIPT_NAME + ' v' + SCRIPT_VERSION + '
'
+ + '' + CMD + ' link [props] [ids...] -- Unidirectional link
'
+ + '' + CMD + ' unlink [props] [ids...] -- Remove link
'
+ + '' + CMD + ' chain [props] [ids...] -- Bidirectional ring
'
+ + '' + CMD + ' unchain [props] [ids...] -- Remove chain
'
+ + '' + CMD + ' status -- Show links for selected
'
+ + '' + CMD + ' --help -- This help
'
+ + '
Properties: ' + ALL_PROPS.join(', ');
+
+ // =========================================================================
+ // Command Router
+ // =========================================================================
+
+ const handleInput = (msg) => {
+ if (msg.type !== 'api') return;
+ if (msg.content.split(' ')[0] !== CMD) return;
+ if (!playerIsGM(msg.playerid) && msg.playerid !== 'API') return;
+
+ const args = msg.content.slice(CMD.length).trim().split(/\s+/).filter(Boolean);
+ const sub = (args.shift() || '').toLowerCase();
+
+ switch (sub) {
+ case 'link': doLink(msg, args); break;
+ case 'unlink': doUnlink(msg, args); break;
+ case 'chain': doChain(msg, args); break;
+ case 'unchain': doUnchain(msg, args); break;
+ case 'status': doStatus(msg); break;
+ case '--help': reply(msg, HELP_TEXT); break;
+ default: reply(msg, HELP_TEXT); break;
+ }
+ };
+
+ // =========================================================================
+ // Initialization
+ // =========================================================================
+
+ const checkInstall = () => {
+ ensureState();
+ log('-=> ' + SCRIPT_NAME + ' v' + SCRIPT_VERSION + ' Initialized <=-');
+ };
+
+ const registerEventHandlers = () => {
+ on('chat:message', handleInput);
+ // Listen to all property changes we care about
+ on('change:graphic:left', onGraphicChanged);
+ on('change:graphic:top', onGraphicChanged);
+ on('change:graphic:rotation', onGraphicChanged);
+ on('change:graphic:width', onGraphicChanged);
+ on('change:graphic:height', onGraphicChanged);
+ on('change:graphic:layer', onGraphicChanged);
+ on('change:graphic:flipv', onGraphicChanged);
+ on('change:graphic:fliph', onGraphicChanged);
+ on('change:graphic:bar1_value', onGraphicChanged);
+ on('change:graphic:bar1_max', onGraphicChanged);
+ on('change:graphic:bar2_value', onGraphicChanged);
+ on('change:graphic:bar2_max', onGraphicChanged);
+ on('change:graphic:bar3_value', onGraphicChanged);
+ on('change:graphic:bar3_max', onGraphicChanged);
+ on('change:graphic:statusmarkers', onGraphicChanged);
+ on('change:graphic:name', onGraphicChanged);
+ on('change:graphic:tint_color', onGraphicChanged);
+ on('change:graphic:light_radius', onGraphicChanged);
+ on('change:graphic:light_dimradius', onGraphicChanged);
+ on('change:graphic:currentSide', onGraphicChanged);
+ };
+
+ return { checkInstall, registerEventHandlers };
+})();
+
+on('ready', () => {
+ 'use strict';
+ Mirror.checkInstall();
+ Mirror.registerEventHandlers();
+});
From cc212d3b3f8a8de3ca8d94dcc6bea6cd3d178764 Mon Sep 17 00:00:00 2001
From: Kenan Millet
Date: Mon, 15 Jun 2026 08:19:20 -0400
Subject: [PATCH 02/28] Mirror: rewrite with single change event, property
groups, API exposure, hard-lock for links
---
Mirror/Mirror.js | 300 +++++++++++++++++++++++++++++------------------
1 file changed, 188 insertions(+), 112 deletions(-)
diff --git a/Mirror/Mirror.js b/Mirror/Mirror.js
index 649f4e8094..22a5b55ba9 100644
--- a/Mirror/Mirror.js
+++ b/Mirror/Mirror.js
@@ -7,16 +7,17 @@
// Flat property syncing between tokens. No transforms, no offsets -- when a
// property changes on one token, the same value is copied to linked tokens.
// Supports unidirectional (link) and bidirectional ring (chain) modes.
+// Unidirectional links hard-lock children by default (changes reverted).
//
// Dependencies: none
//
// Commands:
-// !mirror link [props] [ids...] Link selected/listed tokens (unidirectional)
-// !mirror unlink [props] [ids...] Remove link (or specific properties)
-// !mirror chain [props] [ids...] Bidirectional ring link
-// !mirror unchain [props] [ids...] Remove chain (or specific properties)
-// !mirror status Show mirror state for selected tokens
-// !mirror --help Command reference
+// !mirror link [--soft] [props/groups] [ids...] Unidirectional link
+// !mirror unlink [props/groups] [ids...] Remove link or properties
+// !mirror chain [props/groups] [ids...] Bidirectional ring link
+// !mirror unchain [props/groups] [ids...] Remove chain or properties
+// !mirror status Show links for selected
+// !mirror --help Command reference
// =============================================================================
/* global on, sendChat, getObj, findObjs, playerIsGM, log, state */
@@ -43,6 +44,21 @@ var Mirror = Mirror || (() => {
'baseOpacity', 'currentSide'
];
+ // Property groups
+ const PROP_GROUPS = {
+ position: ['left', 'top'],
+ size: ['width', 'height'],
+ spatial: ['left', 'top', 'rotation', 'width', 'height'],
+ bars: ['bar1_value', 'bar1_max', 'bar2_value', 'bar2_max', 'bar3_value', 'bar3_max'],
+ light: ['light_radius', 'light_dimradius', 'light_angle', 'light_otherplayers',
+ 'light_hassight', 'light_losangle', 'light_multiplier',
+ 'has_bright_light_vision', 'has_night_vision', 'night_vision_distance',
+ 'emits_bright_light', 'bright_light_distance', 'emits_low_light', 'low_light_distance'],
+ auras: ['aura1_radius', 'aura1_color', 'aura1_square', 'aura2_radius', 'aura2_color', 'aura2_square'],
+ flip: ['flipv', 'fliph'],
+ all: ALL_PROPS.slice()
+ };
+
// =========================================================================
// Helpers
// =========================================================================
@@ -65,10 +81,34 @@ var Mirror = Mirror || (() => {
const ensureState = () => {
if (!state[SCRIPT_NAME]) {
state[SCRIPT_NAME] = {
- // linkId → { props: [...], ids: [...], mode: 'link'|'chain' }
- links: {}
+ // linkId → { props: [...], ids: [...], mode: 'link'|'chain', soft: bool }
+ links: {},
+ // Set of token IDs that are part of any chain (for fast lookup)
+ chainedIds: {}
};
}
+ if (!state[SCRIPT_NAME].chainedIds) state[SCRIPT_NAME].chainedIds = {};
+ };
+
+ // =========================================================================
+ // Property Resolution
+ // =========================================================================
+
+ const resolveProps = (args) => {
+ var props = [];
+ var remaining = [];
+ args.forEach(function(arg) {
+ if (PROP_GROUPS[arg]) {
+ props = props.concat(PROP_GROUPS[arg]);
+ } else if (ALL_PROPS.indexOf(arg) !== -1) {
+ props.push(arg);
+ } else {
+ remaining.push(arg);
+ }
+ });
+ // Deduplicate props
+ props = props.filter(function(p, i) { return props.indexOf(p) === i; });
+ return { props: props, remaining: remaining };
};
// =========================================================================
@@ -79,20 +119,24 @@ var Mirror = Mirror || (() => {
return (msg.selected || []).map(function(s) { return s._id; }).filter(Boolean);
};
- const parseArgs = (args) => {
- var props = [];
- var ids = [];
- args.forEach(function(arg) {
- if (ALL_PROPS.indexOf(arg) !== -1) props.push(arg);
- else if (arg.startsWith('-')) ids.push(arg); // Roll20 IDs start with -
- });
- return { props: props, ids: ids };
+ const parseCommand = (msg, args) => {
+ var soft = args.indexOf('--soft') !== -1;
+ args = args.filter(function(a) { return a !== '--soft'; });
+ var resolved = resolveProps(args);
+ var ids = resolved.remaining.filter(function(a) { return a.startsWith('-'); });
+ ids = ids.concat(getSelectedIds(msg));
+ ids = ids.filter(function(id, i) { return ids.indexOf(id) === i; }); // dedupe
+ var props = resolved.props.length > 0 ? resolved.props : ALL_PROPS.slice();
+ return { props: props, ids: ids, soft: soft };
};
- const createLink = (mode, props, ids) => {
+ const createLink = (mode, props, ids, soft) => {
var s = state[SCRIPT_NAME];
var linkId = genId();
- s.links[linkId] = { props: props, ids: ids, mode: mode };
+ s.links[linkId] = { props: props, ids: ids, mode: mode, soft: soft };
+ if (mode === 'chain') {
+ ids.forEach(function(id) { s.chainedIds[id] = true; });
+ }
return linkId;
};
@@ -110,52 +154,94 @@ var Mirror = Mirror || (() => {
var link = s.links[linkId];
if (!link) return;
if (!propsToRemove || propsToRemove.length === 0) {
+ // Remove entire link
+ if (link.mode === 'chain') {
+ link.ids.forEach(function(id) { rebuildChainedIds(id, linkId); });
+ }
delete s.links[linkId];
} else {
link.props = link.props.filter(function(p) { return propsToRemove.indexOf(p) === -1; });
- if (link.props.length === 0) delete s.links[linkId];
+ if (link.props.length === 0) {
+ if (link.mode === 'chain') {
+ link.ids.forEach(function(id) { rebuildChainedIds(id, linkId); });
+ }
+ delete s.links[linkId];
+ }
}
};
+ const rebuildChainedIds = (tokenId, excludeLinkId) => {
+ var s = state[SCRIPT_NAME];
+ // Check if token is still in any other chain
+ var stillChained = Object.entries(s.links).some(function(entry) {
+ return entry[0] !== excludeLinkId && entry[1].mode === 'chain' && entry[1].ids.indexOf(tokenId) !== -1;
+ });
+ if (!stillChained) delete s.chainedIds[tokenId];
+ };
+
// =========================================================================
// Sync Engine
// =========================================================================
var syncing = false;
- const onGraphicChanged = (obj) => {
+ const onGraphicChanged = (obj, prev) => {
if (syncing) return;
var s = state[SCRIPT_NAME];
var tokenId = obj.get('id');
- // Find all links this token participates in
+ // Find changed properties
+ var changed = [];
+ ALL_PROPS.forEach(function(prop) {
+ if (prev[prop] !== undefined && prev[prop] !== obj.get(prop)) {
+ changed.push(prop);
+ }
+ });
+ if (changed.length === 0) return;
+
Object.values(s.links).forEach(function(link) {
var idx = link.ids.indexOf(tokenId);
if (idx === -1) return;
- // Determine which tokens to update
- var targets;
+ // Determine relevant changed props for this link
+ var relevantProps = changed.filter(function(p) { return link.props.indexOf(p) !== -1; });
+ if (relevantProps.length === 0) return;
+
if (link.mode === 'chain') {
- // Bidirectional: update all others in the link
- targets = link.ids.filter(function(id) { return id !== tokenId; });
+ // Bidirectional: propagate to all others
+ var updates = {};
+ relevantProps.forEach(function(p) { updates[p] = obj.get(p); });
+ syncing = true;
+ link.ids.forEach(function(id) {
+ if (id === tokenId) return;
+ var target = getObj('graphic', id);
+ if (target) target.set(updates);
+ });
+ syncing = false;
} else {
- // Unidirectional: only propagate if this is the source (first id)
- if (idx !== 0) return;
- targets = link.ids.slice(1);
+ // Unidirectional
+ if (idx === 0) {
+ // Source changed: propagate to targets
+ var updates = {};
+ relevantProps.forEach(function(p) { updates[p] = obj.get(p); });
+ syncing = true;
+ link.ids.slice(1).forEach(function(id) {
+ var target = getObj('graphic', id);
+ if (target) target.set(updates);
+ });
+ syncing = false;
+ } else if (!link.soft) {
+ // Hard lock: revert child to source value
+ var source = getObj('graphic', link.ids[0]);
+ if (source) {
+ var revert = {};
+ relevantProps.forEach(function(p) { revert[p] = source.get(p); });
+ syncing = true;
+ obj.set(revert);
+ syncing = false;
+ }
+ }
}
-
- // Build updates from changed properties that are in this link's prop list
- var updates = {};
- link.props.forEach(function(prop) {
- updates[prop] = obj.get(prop);
- });
-
- syncing = true;
- targets.forEach(function(targetId) {
- var target = getObj('graphic', targetId);
- if (target) target.set(updates);
- });
- syncing = false;
});
};
@@ -164,72 +250,48 @@ var Mirror = Mirror || (() => {
// =========================================================================
const doLink = (msg, args) => {
- var parsed = parseArgs(args);
- var ids = parsed.ids.concat(getSelectedIds(msg));
- // Deduplicate
- ids = ids.filter(function(id, i) { return ids.indexOf(id) === i; });
-
- if (ids.length < 2) { reply(msg, 'Error', 'Link requires at least 2 tokens.'); return; }
- var props = parsed.props.length > 0 ? parsed.props : ALL_PROPS.slice();
- var linkId = createLink('link', props, ids);
- reply(msg, 'Link', 'Linked ' + ids.length + ' tokens (' + props.length + ' properties). Source: first selected/listed.');
+ var parsed = parseCommand(msg, args);
+ if (parsed.ids.length < 2) { reply(msg, 'Error', 'Link requires at least 2 tokens.'); return; }
+ createLink('link', parsed.props, parsed.ids, parsed.soft);
+ reply(msg, 'Link', 'Linked ' + parsed.ids.length + ' tokens (' + parsed.props.length + ' props' + (parsed.soft ? ', soft' : ', hard-lock') + '). Source: first selected.');
};
const doUnlink = (msg, args) => {
- var parsed = parseArgs(args);
- var ids = parsed.ids.concat(getSelectedIds(msg));
- ids = ids.filter(function(id, i) { return ids.indexOf(id) === i; });
-
- if (ids.length === 0) { reply(msg, 'Error', 'Select or specify token(s).'); return; }
-
+ var parsed = parseCommand(msg, args);
+ if (parsed.ids.length === 0) { reply(msg, 'Error', 'Select or specify token(s).'); return; }
+ var propsToRemove = parsed.props.length > 0 && parsed.props.length < ALL_PROPS.length ? parsed.props : null;
var removed = 0;
- ids.forEach(function(id) {
- var links = findLinksForToken(id);
- links.forEach(function(entry) {
- if (entry.link.mode === 'link') {
- removePropsFromLink(entry.id, parsed.props.length > 0 ? parsed.props : null);
- removed++;
- }
+ parsed.ids.forEach(function(id) {
+ findLinksForToken(id).forEach(function(entry) {
+ if (entry.link.mode === 'link') { removePropsFromLink(entry.id, propsToRemove); removed++; }
});
});
- reply(msg, 'Unlink', 'Removed ' + removed + ' link(s).');
+ reply(msg, 'Unlink', 'Processed ' + removed + ' link(s).');
};
const doChain = (msg, args) => {
- var parsed = parseArgs(args);
- var ids = parsed.ids.concat(getSelectedIds(msg));
- ids = ids.filter(function(id, i) { return ids.indexOf(id) === i; });
-
- if (ids.length < 2) { reply(msg, 'Error', 'Chain requires at least 2 tokens.'); return; }
- var props = parsed.props.length > 0 ? parsed.props : ALL_PROPS.slice();
- var linkId = createLink('chain', props, ids);
- reply(msg, 'Chain', 'Chain-linked ' + ids.length + ' tokens (' + props.length + ' properties).');
+ var parsed = parseCommand(msg, args);
+ if (parsed.ids.length < 2) { reply(msg, 'Error', 'Chain requires at least 2 tokens.'); return; }
+ createLink('chain', parsed.props, parsed.ids, true);
+ reply(msg, 'Chain', 'Chain-linked ' + parsed.ids.length + ' tokens (' + parsed.props.length + ' props).');
};
const doUnchain = (msg, args) => {
- var parsed = parseArgs(args);
- var ids = parsed.ids.concat(getSelectedIds(msg));
- ids = ids.filter(function(id, i) { return ids.indexOf(id) === i; });
-
- if (ids.length === 0) { reply(msg, 'Error', 'Select or specify token(s).'); return; }
-
+ var parsed = parseCommand(msg, args);
+ if (parsed.ids.length === 0) { reply(msg, 'Error', 'Select or specify token(s).'); return; }
+ var propsToRemove = parsed.props.length > 0 && parsed.props.length < ALL_PROPS.length ? parsed.props : null;
var removed = 0;
- ids.forEach(function(id) {
- var links = findLinksForToken(id);
- links.forEach(function(entry) {
- if (entry.link.mode === 'chain') {
- removePropsFromLink(entry.id, parsed.props.length > 0 ? parsed.props : null);
- removed++;
- }
+ parsed.ids.forEach(function(id) {
+ findLinksForToken(id).forEach(function(entry) {
+ if (entry.link.mode === 'chain') { removePropsFromLink(entry.id, propsToRemove); removed++; }
});
});
- reply(msg, 'Unchain', 'Removed ' + removed + ' chain(s).');
+ reply(msg, 'Unchain', 'Processed ' + removed + ' chain(s).');
};
const doStatus = (msg) => {
var ids = getSelectedIds(msg);
if (ids.length === 0) { reply(msg, 'Error', 'Select token(s).'); return; }
-
var out = '';
ids.forEach(function(id) {
var obj = getObj('graphic', id);
@@ -238,20 +300,22 @@ var Mirror = Mirror || (() => {
out += '' + name + ': ';
if (links.length === 0) { out += 'no mirror links
'; return; }
links.forEach(function(entry) {
- out += entry.link.mode + ' (' + entry.link.props.length + ' props, ' + entry.link.ids.length + ' tokens)
';
+ var role = entry.link.mode === 'chain' ? 'chain' : (entry.link.ids[0] === id ? 'source' : 'target');
+ out += role + ' (' + entry.link.props.length + ' props, ' + entry.link.ids.length + ' tokens' + (entry.link.soft ? ', soft' : '') + ')
';
});
});
reply(msg, out);
};
const HELP_TEXT = '' + SCRIPT_NAME + ' v' + SCRIPT_VERSION + '
'
- + '' + CMD + ' link [props] [ids...] -- Unidirectional link
'
+ + '' + CMD + ' link [--soft] [props] [ids...] -- Unidirectional (hard-lock by default)
'
+ '' + CMD + ' unlink [props] [ids...] -- Remove link
'
+ '' + CMD + ' chain [props] [ids...] -- Bidirectional ring
'
+ '' + CMD + ' unchain [props] [ids...] -- Remove chain
'
+ '' + CMD + ' status -- Show links for selected
'
+ '' + CMD + ' --help -- This help
'
- + '
Properties: ' + ALL_PROPS.join(', ');
+ + '
Groups: all, spatial, position, size, bars, light, auras, flip
'
+ + '
Props: ' + ALL_PROPS.join(', ');
// =========================================================================
// Command Router
@@ -276,6 +340,30 @@ var Mirror = Mirror || (() => {
}
};
+ // =========================================================================
+ // Public API
+ // =========================================================================
+
+ const link = (ids, props, soft) => {
+ if (!ids || ids.length < 2) { log(SCRIPT_NAME + ': link requires at least 2 IDs.'); return null; }
+ return createLink('link', props || ALL_PROPS.slice(), ids, !!soft);
+ };
+
+ const chainLink = (ids, props) => {
+ if (!ids || ids.length < 2) { log(SCRIPT_NAME + ': chainLink requires at least 2 IDs.'); return null; }
+ return createLink('chain', props || ALL_PROPS.slice(), ids, true);
+ };
+
+ const unlink = (ids, props) => {
+ var s = state[SCRIPT_NAME];
+ var propsToRemove = (props && props.length > 0) ? props : null;
+ ids.forEach(function(id) {
+ findLinksForToken(id).forEach(function(entry) {
+ removePropsFromLink(entry.id, propsToRemove);
+ });
+ });
+ };
+
// =========================================================================
// Initialization
// =========================================================================
@@ -287,30 +375,18 @@ var Mirror = Mirror || (() => {
const registerEventHandlers = () => {
on('chat:message', handleInput);
- // Listen to all property changes we care about
- on('change:graphic:left', onGraphicChanged);
- on('change:graphic:top', onGraphicChanged);
- on('change:graphic:rotation', onGraphicChanged);
- on('change:graphic:width', onGraphicChanged);
- on('change:graphic:height', onGraphicChanged);
- on('change:graphic:layer', onGraphicChanged);
- on('change:graphic:flipv', onGraphicChanged);
- on('change:graphic:fliph', onGraphicChanged);
- on('change:graphic:bar1_value', onGraphicChanged);
- on('change:graphic:bar1_max', onGraphicChanged);
- on('change:graphic:bar2_value', onGraphicChanged);
- on('change:graphic:bar2_max', onGraphicChanged);
- on('change:graphic:bar3_value', onGraphicChanged);
- on('change:graphic:bar3_max', onGraphicChanged);
- on('change:graphic:statusmarkers', onGraphicChanged);
- on('change:graphic:name', onGraphicChanged);
- on('change:graphic:tint_color', onGraphicChanged);
- on('change:graphic:light_radius', onGraphicChanged);
- on('change:graphic:light_dimradius', onGraphicChanged);
- on('change:graphic:currentSide', onGraphicChanged);
+ on('change:graphic', onGraphicChanged);
};
- return { checkInstall, registerEventHandlers };
+ return {
+ checkInstall,
+ registerEventHandlers,
+ link: link,
+ chainLink: chainLink,
+ unlink: unlink,
+ ALL_PROPS: ALL_PROPS,
+ PROP_GROUPS: PROP_GROUPS
+ };
})();
on('ready', () => {
From bb5db30279d376f03c276ffc7750312ed1411cb2 Mon Sep 17 00:00:00 2001
From: Kenan Millet
Date: Mon, 15 Jun 2026 08:44:02 -0400
Subject: [PATCH 03/28] Mirror: add --align flag, standalone align command
---
Mirror/Mirror.js | 35 +++++++++++++++++++++++++++++++----
1 file changed, 31 insertions(+), 4 deletions(-)
diff --git a/Mirror/Mirror.js b/Mirror/Mirror.js
index 22a5b55ba9..84f9d94fb3 100644
--- a/Mirror/Mirror.js
+++ b/Mirror/Mirror.js
@@ -121,13 +121,29 @@ var Mirror = Mirror || (() => {
const parseCommand = (msg, args) => {
var soft = args.indexOf('--soft') !== -1;
- args = args.filter(function(a) { return a !== '--soft'; });
+ var align = args.indexOf('--align') !== -1;
+ args = args.filter(function(a) { return a !== '--soft' && a !== '--align'; });
var resolved = resolveProps(args);
var ids = resolved.remaining.filter(function(a) { return a.startsWith('-'); });
ids = ids.concat(getSelectedIds(msg));
ids = ids.filter(function(id, i) { return ids.indexOf(id) === i; }); // dedupe
var props = resolved.props.length > 0 ? resolved.props : ALL_PROPS.slice();
- return { props: props, ids: ids, soft: soft };
+ return { props: props, ids: ids, soft: soft, align: align };
+ };
+
+ /**
+ * Align targets to source: copy specified props from first token to all others.
+ */
+ const alignTokens = (ids, props) => {
+ if (ids.length < 2) return;
+ var source = getObj('graphic', ids[0]);
+ if (!source) return;
+ var updates = {};
+ props.forEach(function(p) { updates[p] = source.get(p); });
+ for (var i = 1; i < ids.length; i++) {
+ var target = getObj('graphic', ids[i]);
+ if (target) target.set(updates);
+ }
};
const createLink = (mode, props, ids, soft) => {
@@ -253,7 +269,8 @@ var Mirror = Mirror || (() => {
var parsed = parseCommand(msg, args);
if (parsed.ids.length < 2) { reply(msg, 'Error', 'Link requires at least 2 tokens.'); return; }
createLink('link', parsed.props, parsed.ids, parsed.soft);
- reply(msg, 'Link', 'Linked ' + parsed.ids.length + ' tokens (' + parsed.props.length + ' props' + (parsed.soft ? ', soft' : ', hard-lock') + '). Source: first selected.');
+ if (parsed.align) alignTokens(parsed.ids, parsed.props);
+ reply(msg, 'Link', 'Linked ' + parsed.ids.length + ' tokens (' + parsed.props.length + ' props' + (parsed.soft ? ', soft' : ', hard-lock') + (parsed.align ? ', aligned' : '') + '). Source: first selected.');
};
const doUnlink = (msg, args) => {
@@ -273,7 +290,8 @@ var Mirror = Mirror || (() => {
var parsed = parseCommand(msg, args);
if (parsed.ids.length < 2) { reply(msg, 'Error', 'Chain requires at least 2 tokens.'); return; }
createLink('chain', parsed.props, parsed.ids, true);
- reply(msg, 'Chain', 'Chain-linked ' + parsed.ids.length + ' tokens (' + parsed.props.length + ' props).');
+ if (parsed.align) alignTokens(parsed.ids, parsed.props);
+ reply(msg, 'Chain', 'Chain-linked ' + parsed.ids.length + ' tokens (' + parsed.props.length + ' props' + (parsed.align ? ', aligned' : '') + ').');
};
const doUnchain = (msg, args) => {
@@ -289,6 +307,13 @@ var Mirror = Mirror || (() => {
reply(msg, 'Unchain', 'Processed ' + removed + ' chain(s).');
};
+ const doAlign = (msg, args) => {
+ var parsed = parseCommand(msg, args);
+ if (parsed.ids.length < 2) { reply(msg, 'Error', 'Align requires at least 2 tokens.'); return; }
+ alignTokens(parsed.ids, parsed.props);
+ reply(msg, 'Align', 'Aligned ' + (parsed.ids.length - 1) + ' token(s) to source (' + parsed.props.length + ' props).');
+ };
+
const doStatus = (msg) => {
var ids = getSelectedIds(msg);
if (ids.length === 0) { reply(msg, 'Error', 'Select token(s).'); return; }
@@ -312,6 +337,7 @@ var Mirror = Mirror || (() => {
+ '' + CMD + ' unlink [props] [ids...] -- Remove link
'
+ '' + CMD + ' chain [props] [ids...] -- Bidirectional ring
'
+ '' + CMD + ' unchain [props] [ids...] -- Remove chain
'
+ + '' + CMD + ' align [props] [ids...] -- Copy props from first to others (one-shot)
'
+ '' + CMD + ' status -- Show links for selected
'
+ '' + CMD + ' --help -- This help
'
+ '
Groups: all, spatial, position, size, bars, light, auras, flip
'
@@ -334,6 +360,7 @@ var Mirror = Mirror || (() => {
case 'unlink': doUnlink(msg, args); break;
case 'chain': doChain(msg, args); break;
case 'unchain': doUnchain(msg, args); break;
+ case 'align': doAlign(msg, args); break;
case 'status': doStatus(msg); break;
case '--help': reply(msg, HELP_TEXT); break;
default: reply(msg, HELP_TEXT); break;
From 183fe25da957cd3ca3576f60ded526d01d60ae53 Mon Sep 17 00:00:00 2001
From: Kenan Millet
Date: Mon, 15 Jun 2026 08:54:17 -0400
Subject: [PATCH 04/28] Mirror: dynamic knownProps set, Object.keys(prev) for
future-proof sync, 'all' meta-group
---
Mirror/Mirror.js | 35 ++++++++++++++++++++---------------
1 file changed, 20 insertions(+), 15 deletions(-)
diff --git a/Mirror/Mirror.js b/Mirror/Mirror.js
index 84f9d94fb3..9ddce68ff2 100644
--- a/Mirror/Mirror.js
+++ b/Mirror/Mirror.js
@@ -55,8 +55,7 @@ var Mirror = Mirror || (() => {
'has_bright_light_vision', 'has_night_vision', 'night_vision_distance',
'emits_bright_light', 'bright_light_distance', 'emits_low_light', 'low_light_distance'],
auras: ['aura1_radius', 'aura1_color', 'aura1_square', 'aura2_radius', 'aura2_color', 'aura2_square'],
- flip: ['flipv', 'fliph'],
- all: ALL_PROPS.slice()
+ flip: ['flipv', 'fliph']
};
// =========================================================================
@@ -81,15 +80,19 @@ var Mirror = Mirror || (() => {
const ensureState = () => {
if (!state[SCRIPT_NAME]) {
state[SCRIPT_NAME] = {
- // linkId → { props: [...], ids: [...], mode: 'link'|'chain', soft: bool }
links: {},
- // Set of token IDs that are part of any chain (for fast lookup)
- chainedIds: {}
+ chainedIds: {},
+ knownProps: {}
};
}
if (!state[SCRIPT_NAME].chainedIds) state[SCRIPT_NAME].chainedIds = {};
+ if (!state[SCRIPT_NAME].knownProps) state[SCRIPT_NAME].knownProps = {};
+ // Seed known props from ALL_PROPS
+ ALL_PROPS.forEach(function(p) { state[SCRIPT_NAME].knownProps[p] = true; });
};
+ const getKnownProps = () => Object.keys(state[SCRIPT_NAME].knownProps);
+
// =========================================================================
// Property Resolution
// =========================================================================
@@ -98,7 +101,9 @@ var Mirror = Mirror || (() => {
var props = [];
var remaining = [];
args.forEach(function(arg) {
- if (PROP_GROUPS[arg]) {
+ if (arg === 'all') {
+ props = props.concat(getKnownProps());
+ } else if (PROP_GROUPS[arg]) {
props = props.concat(PROP_GROUPS[arg]);
} else if (ALL_PROPS.indexOf(arg) !== -1) {
props.push(arg);
@@ -127,7 +132,7 @@ var Mirror = Mirror || (() => {
var ids = resolved.remaining.filter(function(a) { return a.startsWith('-'); });
ids = ids.concat(getSelectedIds(msg));
ids = ids.filter(function(id, i) { return ids.indexOf(id) === i; }); // dedupe
- var props = resolved.props.length > 0 ? resolved.props : ALL_PROPS.slice();
+ var props = resolved.props.length > 0 ? resolved.props : getKnownProps();
return { props: props, ids: ids, soft: soft, align: align };
};
@@ -206,15 +211,15 @@ var Mirror = Mirror || (() => {
var s = state[SCRIPT_NAME];
var tokenId = obj.get('id');
- // Find changed properties
- var changed = [];
- ALL_PROPS.forEach(function(prop) {
- if (prev[prop] !== undefined && prev[prop] !== obj.get(prop)) {
- changed.push(prop);
- }
+ // Find changed properties dynamically from prev keys
+ var changed = Object.keys(prev).filter(function(k) {
+ return !k.startsWith('_') && prev[k] !== obj.get(k);
});
if (changed.length === 0) return;
+ // Grow known props set with any discovered properties
+ changed.forEach(function(p) { s.knownProps[p] = true; });
+
Object.values(s.links).forEach(function(link) {
var idx = link.ids.indexOf(tokenId);
if (idx === -1) return;
@@ -373,12 +378,12 @@ var Mirror = Mirror || (() => {
const link = (ids, props, soft) => {
if (!ids || ids.length < 2) { log(SCRIPT_NAME + ': link requires at least 2 IDs.'); return null; }
- return createLink('link', props || ALL_PROPS.slice(), ids, !!soft);
+ return createLink('link', props || getKnownProps(), ids, !!soft);
};
const chainLink = (ids, props) => {
if (!ids || ids.length < 2) { log(SCRIPT_NAME + ': chainLink requires at least 2 IDs.'); return null; }
- return createLink('chain', props || ALL_PROPS.slice(), ids, true);
+ return createLink('chain', props || getKnownProps(), ids, true);
};
const unlink = (ids, props) => {
From da9df0755ebf50420f5cddd7a2b3291bf6f3067c Mon Sep 17 00:00:00 2001
From: Kenan Millet
Date: Mon, 15 Jun 2026 09:10:32 -0400
Subject: [PATCH 05/28] Mirror: rewrite align with --linked/--unlinked
semantics, respect link direction
---
Mirror/Mirror.js | 82 ++++++++++++++++++++++++++++++++++++++++++++++--
1 file changed, 80 insertions(+), 2 deletions(-)
diff --git a/Mirror/Mirror.js b/Mirror/Mirror.js
index 9ddce68ff2..38d24c27cf 100644
--- a/Mirror/Mirror.js
+++ b/Mirror/Mirror.js
@@ -313,10 +313,88 @@ var Mirror = Mirror || (() => {
};
const doAlign = (msg, args) => {
+ var linked = args.indexOf('--linked') !== -1;
+ var unlinked = args.indexOf('--unlinked') !== -1;
+ args = args.filter(function(a) { return a !== '--linked' && a !== '--unlinked'; });
+ // Default: --linked only
+ if (!linked && !unlinked) linked = true;
+
var parsed = parseCommand(msg, args);
if (parsed.ids.length < 2) { reply(msg, 'Error', 'Align requires at least 2 tokens.'); return; }
- alignTokens(parsed.ids, parsed.props);
- reply(msg, 'Align', 'Aligned ' + (parsed.ids.length - 1) + ' token(s) to source (' + parsed.props.length + ' props).');
+
+ var s = state[SCRIPT_NAME];
+ var aligned = 0;
+ var ignored = [];
+
+ if (linked) {
+ parsed.ids.forEach(function(id) {
+ var links = findLinksForToken(id);
+ links.forEach(function(entry) {
+ var link = entry.link;
+ var props = parsed.props;
+ if (link.mode === 'chain') {
+ // Align to the first selected/passed id that is in this chain
+ var sourceId = parsed.ids.find(function(pid) { return link.ids.indexOf(pid) !== -1; });
+ if (!sourceId) return;
+ var source = getObj('graphic', sourceId);
+ if (!source) return;
+ var updates = {};
+ props.forEach(function(p) { updates[p] = source.get(p); });
+ link.ids.forEach(function(tid) {
+ if (tid === sourceId) return;
+ var t = getObj('graphic', tid);
+ if (t) { t.set(updates); aligned++; }
+ });
+ } else {
+ // One-way: parent aligns children, or child aligns to parent
+ var sourceIdx = link.ids.indexOf(id);
+ if (sourceIdx === 0) {
+ // This is the parent — align children to it
+ var source = getObj('graphic', id);
+ if (!source) return;
+ var updates = {};
+ props.forEach(function(p) { updates[p] = source.get(p); });
+ link.ids.slice(1).forEach(function(tid) {
+ var t = getObj('graphic', tid);
+ if (t) { t.set(updates); aligned++; }
+ });
+ } else {
+ // This is a child — align to parent
+ var source = getObj('graphic', link.ids[0]);
+ if (!source) return;
+ var updates = {};
+ props.forEach(function(p) { updates[p] = source.get(p); });
+ var target = getObj('graphic', id);
+ if (target) { target.set(updates); aligned++; }
+ }
+ }
+ });
+ if (links.length === 0) ignored.push(id);
+ });
+ }
+
+ if (unlinked) {
+ // Align unlinked tokens to first id in selection
+ var sourceId = parsed.ids[0];
+ var source = getObj('graphic', sourceId);
+ if (source) {
+ var updates = {};
+ parsed.props.forEach(function(p) { updates[p] = source.get(p); });
+ parsed.ids.slice(1).forEach(function(id) {
+ var links = findLinksForToken(id);
+ if (links.length === 0) {
+ var t = getObj('graphic', id);
+ if (t) { t.set(updates); aligned++; }
+ }
+ });
+ }
+ }
+
+ var out = 'Aligned ' + aligned + ' token(s).';
+ if (ignored.length > 0 && linked && !unlinked) {
+ out += '
' + ignored.length + ' token(s) ignored (not linked).';
+ }
+ reply(msg, 'Align', out);
};
const doStatus = (msg) => {
From 5206fce220f6fb5ffdfdb5ffa0f67fb566a593a8 Mon Sep 17 00:00:00 2001
From: Kenan Millet
Date: Mon, 15 Jun 2026 09:12:55 -0400
Subject: [PATCH 06/28] Mirror: show token IDs in status output
---
Mirror/Mirror.js | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/Mirror/Mirror.js b/Mirror/Mirror.js
index 38d24c27cf..382237f929 100644
--- a/Mirror/Mirror.js
+++ b/Mirror/Mirror.js
@@ -403,9 +403,9 @@ var Mirror = Mirror || (() => {
var out = '';
ids.forEach(function(id) {
var obj = getObj('graphic', id);
- var name = obj ? (obj.get('name') || id) : id;
+ var name = obj ? (obj.get('name') || '(unnamed)') : '?';
var links = findLinksForToken(id);
- out += '' + name + ': ';
+ out += '' + name + ' (' + id + '): ';
if (links.length === 0) { out += 'no mirror links
'; return; }
links.forEach(function(entry) {
var role = entry.link.mode === 'chain' ? 'chain' : (entry.link.ids[0] === id ? 'source' : 'target');
From 8d49cffa0c2489e53d723409b9a9360e4f8ba6a6 Mon Sep 17 00:00:00 2001
From: Kenan Millet
Date: Mon, 15 Jun 2026 09:28:23 -0400
Subject: [PATCH 07/28] Mirror: add --exclude, global excludes config, smart
unchain/unlink (add to excludes for 'all' links)
---
Mirror/Mirror.js | 158 +++++++++++++++++++++++++++++++++++++++++------
1 file changed, 138 insertions(+), 20 deletions(-)
diff --git a/Mirror/Mirror.js b/Mirror/Mirror.js
index 382237f929..853b86019c 100644
--- a/Mirror/Mirror.js
+++ b/Mirror/Mirror.js
@@ -87,6 +87,7 @@ var Mirror = Mirror || (() => {
}
if (!state[SCRIPT_NAME].chainedIds) state[SCRIPT_NAME].chainedIds = {};
if (!state[SCRIPT_NAME].knownProps) state[SCRIPT_NAME].knownProps = {};
+ if (!state[SCRIPT_NAME].globalExcludes) state[SCRIPT_NAME].globalExcludes = [];
// Seed known props from ALL_PROPS
ALL_PROPS.forEach(function(p) { state[SCRIPT_NAME].knownProps[p] = true; });
};
@@ -128,12 +129,35 @@ var Mirror = Mirror || (() => {
var soft = args.indexOf('--soft') !== -1;
var align = args.indexOf('--align') !== -1;
args = args.filter(function(a) { return a !== '--soft' && a !== '--align'; });
+
+ // Parse --exclude
+ var excludes = [];
+ var exIdx = args.indexOf('--exclude');
+ if (exIdx !== -1) {
+ var afterExclude = args.slice(exIdx + 1);
+ args = args.slice(0, exIdx);
+ var exResolved = resolveProps(afterExclude);
+ excludes = exResolved.props;
+ // Any remaining non-prop args after --exclude are IDs
+ args = args.concat(exResolved.remaining);
+ }
+
var resolved = resolveProps(args);
var ids = resolved.remaining.filter(function(a) { return a.startsWith('-'); });
ids = ids.concat(getSelectedIds(msg));
ids = ids.filter(function(id, i) { return ids.indexOf(id) === i; }); // dedupe
- var props = resolved.props.length > 0 ? resolved.props : getKnownProps();
- return { props: props, ids: ids, soft: soft, align: align };
+
+ // Determine if using 'all' or specific props
+ var props;
+ if (resolved.props.length === 0) {
+ props = 'all'; // default: all
+ } else if (resolved.props.length === getKnownProps().length) {
+ props = 'all'; // explicit 'all' group resolved to full list
+ } else {
+ props = resolved.props;
+ }
+
+ return { props: props, ids: ids, soft: soft, align: align, excludes: excludes };
};
/**
@@ -151,16 +175,31 @@ var Mirror = Mirror || (() => {
}
};
- const createLink = (mode, props, ids, soft) => {
+ const createLink = (mode, props, ids, soft, excludes) => {
var s = state[SCRIPT_NAME];
var linkId = genId();
- s.links[linkId] = { props: props, ids: ids, mode: mode, soft: soft };
+ s.links[linkId] = { props: props, ids: ids, mode: mode, soft: soft, excludes: excludes || [] };
if (mode === 'chain') {
ids.forEach(function(id) { s.chainedIds[id] = true; });
}
return linkId;
};
+ /**
+ * Get the effective props for a link, accounting for 'all' and excludes.
+ */
+ const getEffectiveProps = (link) => {
+ if (link.props === 'all') {
+ var excludes = (link.excludes || []).concat(getGlobalExcludes());
+ return getKnownProps().filter(function(p) { return excludes.indexOf(p) === -1; });
+ }
+ return link.props;
+ };
+
+ const getGlobalExcludes = () => {
+ return state[SCRIPT_NAME].globalExcludes || [];
+ };
+
const findLinksForToken = (tokenId) => {
var s = state[SCRIPT_NAME];
var results = [];
@@ -225,7 +264,8 @@ var Mirror = Mirror || (() => {
if (idx === -1) return;
// Determine relevant changed props for this link
- var relevantProps = changed.filter(function(p) { return link.props.indexOf(p) !== -1; });
+ var effectiveProps = getEffectiveProps(link);
+ var relevantProps = changed.filter(function(p) { return effectiveProps.indexOf(p) !== -1; });
if (relevantProps.length === 0) return;
if (link.mode === 'chain') {
@@ -273,43 +313,119 @@ var Mirror = Mirror || (() => {
const doLink = (msg, args) => {
var parsed = parseCommand(msg, args);
if (parsed.ids.length < 2) { reply(msg, 'Error', 'Link requires at least 2 tokens.'); return; }
- createLink('link', parsed.props, parsed.ids, parsed.soft);
- if (parsed.align) alignTokens(parsed.ids, parsed.props);
- reply(msg, 'Link', 'Linked ' + parsed.ids.length + ' tokens (' + parsed.props.length + ' props' + (parsed.soft ? ', soft' : ', hard-lock') + (parsed.align ? ', aligned' : '') + '). Source: first selected.');
+ createLink('link', parsed.props, parsed.ids, parsed.soft, parsed.excludes);
+ if (parsed.align) alignTokens(parsed.ids, parsed.props === 'all' ? getKnownProps().filter(function(p) { return parsed.excludes.indexOf(p) === -1; }) : parsed.props);
+ var propCount = parsed.props === 'all' ? 'all' : parsed.props.length;
+ reply(msg, 'Link', 'Linked ' + parsed.ids.length + ' tokens (' + propCount + ' props' + (parsed.soft ? ', soft' : ', hard-lock') + (parsed.excludes.length ? ', ' + parsed.excludes.length + ' excluded' : '') + (parsed.align ? ', aligned' : '') + ').');
};
const doUnlink = (msg, args) => {
var parsed = parseCommand(msg, args);
if (parsed.ids.length === 0) { reply(msg, 'Error', 'Select or specify token(s).'); return; }
- var propsToRemove = parsed.props.length > 0 && parsed.props.length < ALL_PROPS.length ? parsed.props : null;
- var removed = 0;
+ var hasSpecificProps = parsed.props !== 'all';
+ var processed = 0;
parsed.ids.forEach(function(id) {
findLinksForToken(id).forEach(function(entry) {
- if (entry.link.mode === 'link') { removePropsFromLink(entry.id, propsToRemove); removed++; }
+ if (entry.link.mode !== 'link') return;
+ if (!hasSpecificProps) {
+ // No props specified: remove entire link
+ removePropsFromLink(entry.id, null);
+ } else if (entry.link.props === 'all') {
+ // Link uses 'all': add props to excludes
+ parsed.props.forEach(function(p) {
+ if (entry.link.excludes.indexOf(p) === -1) entry.link.excludes.push(p);
+ });
+ } else {
+ // Link uses specific props: remove them
+ removePropsFromLink(entry.id, parsed.props);
+ }
+ processed++;
});
});
- reply(msg, 'Unlink', 'Processed ' + removed + ' link(s).');
+ reply(msg, 'Unlink', 'Processed ' + processed + ' link(s).');
};
const doChain = (msg, args) => {
var parsed = parseCommand(msg, args);
if (parsed.ids.length < 2) { reply(msg, 'Error', 'Chain requires at least 2 tokens.'); return; }
- createLink('chain', parsed.props, parsed.ids, true);
- if (parsed.align) alignTokens(parsed.ids, parsed.props);
- reply(msg, 'Chain', 'Chain-linked ' + parsed.ids.length + ' tokens (' + parsed.props.length + ' props' + (parsed.align ? ', aligned' : '') + ').');
+
+ // Check if tokens are already in an existing chain — if so, re-include props
+ var existingChain = null;
+ var links = findLinksForToken(parsed.ids[0]);
+ for (var i = 0; i < links.length; i++) {
+ if (links[i].link.mode === 'chain') { existingChain = links[i]; break; }
+ }
+
+ if (existingChain && parsed.props !== 'all') {
+ // Re-include: remove specified props from excludes
+ existingChain.link.excludes = (existingChain.link.excludes || []).filter(function(p) {
+ return parsed.props.indexOf(p) === -1;
+ });
+ reply(msg, 'Chain', 'Re-included ' + parsed.props.length + ' prop(s) in existing chain.');
+ } else {
+ createLink('chain', parsed.props, parsed.ids, true, parsed.excludes);
+ if (parsed.align) {
+ var alignProps = parsed.props === 'all' ? getKnownProps().filter(function(p) { return parsed.excludes.indexOf(p) === -1; }) : parsed.props;
+ alignTokens(parsed.ids, alignProps);
+ }
+ var propCount = parsed.props === 'all' ? 'all' : parsed.props.length;
+ reply(msg, 'Chain', 'Chain-linked ' + parsed.ids.length + ' tokens (' + propCount + ' props' + (parsed.excludes.length ? ', ' + parsed.excludes.length + ' excluded' : '') + (parsed.align ? ', aligned' : '') + ').');
+ }
};
const doUnchain = (msg, args) => {
var parsed = parseCommand(msg, args);
if (parsed.ids.length === 0) { reply(msg, 'Error', 'Select or specify token(s).'); return; }
- var propsToRemove = parsed.props.length > 0 && parsed.props.length < ALL_PROPS.length ? parsed.props : null;
- var removed = 0;
+ var hasSpecificProps = parsed.props !== 'all';
+ var processed = 0;
parsed.ids.forEach(function(id) {
findLinksForToken(id).forEach(function(entry) {
- if (entry.link.mode === 'chain') { removePropsFromLink(entry.id, propsToRemove); removed++; }
+ if (entry.link.mode !== 'chain') return;
+ if (!hasSpecificProps) {
+ // No props specified: remove entire chain
+ removePropsFromLink(entry.id, null);
+ } else if (entry.link.props === 'all') {
+ // Link uses 'all': add props to excludes
+ var propsToExclude = parsed.props;
+ propsToExclude.forEach(function(p) {
+ if (entry.link.excludes.indexOf(p) === -1) entry.link.excludes.push(p);
+ });
+ } else {
+ // Link uses specific props: remove them
+ removePropsFromLink(entry.id, parsed.props);
+ }
+ processed++;
});
});
- reply(msg, 'Unchain', 'Processed ' + removed + ' chain(s).');
+ reply(msg, 'Unchain', 'Processed ' + processed + ' chain(s).');
+ };
+
+ const doConfig = (msg, args) => {
+ var s = state[SCRIPT_NAME];
+ if (args.length === 0) {
+ // Show current config
+ reply(msg, 'Config', 'Global excludes: ' + (s.globalExcludes.length > 0 ? s.globalExcludes.join(', ') : '(none)'));
+ return;
+ }
+ var sub = args.shift();
+ if (sub === 'exclude') {
+ var resolved = resolveProps(args);
+ if (resolved.props.length === 0) { reply(msg, 'Error', 'Specify properties to exclude.'); return; }
+ resolved.props.forEach(function(p) {
+ if (s.globalExcludes.indexOf(p) === -1) s.globalExcludes.push(p);
+ });
+ reply(msg, 'Config', 'Global excludes: ' + s.globalExcludes.join(', '));
+ } else if (sub === 'include') {
+ var resolved = resolveProps(args);
+ if (resolved.props.length === 0) { reply(msg, 'Error', 'Specify properties to include.'); return; }
+ s.globalExcludes = s.globalExcludes.filter(function(p) { return resolved.props.indexOf(p) === -1; });
+ reply(msg, 'Config', 'Global excludes: ' + (s.globalExcludes.length > 0 ? s.globalExcludes.join(', ') : '(none)'));
+ } else if (sub === 'reset') {
+ s.globalExcludes = [];
+ reply(msg, 'Config', 'Global excludes cleared.');
+ } else {
+ reply(msg, 'Error', 'Usage: !mirror config [exclude|include|reset] [props]');
+ }
};
const doAlign = (msg, args) => {
@@ -420,7 +536,8 @@ var Mirror = Mirror || (() => {
+ '' + CMD + ' unlink [props] [ids...] -- Remove link
'
+ '' + CMD + ' chain [props] [ids...] -- Bidirectional ring
'
+ '' + CMD + ' unchain [props] [ids...] -- Remove chain
'
- + '' + CMD + ' align [props] [ids...] -- Copy props from first to others (one-shot)
'
+ + '' + CMD + ' align [--linked|--unlinked] [props] [ids...] -- Align tokens
'
+ + '' + CMD + ' config [exclude|include|reset] [props] -- Global excludes
'
+ '' + CMD + ' status -- Show links for selected
'
+ '' + CMD + ' --help -- This help
'
+ '
Groups: all, spatial, position, size, bars, light, auras, flip
'
@@ -444,6 +561,7 @@ var Mirror = Mirror || (() => {
case 'chain': doChain(msg, args); break;
case 'unchain': doUnchain(msg, args); break;
case 'align': doAlign(msg, args); break;
+ case 'config': doConfig(msg, args); break;
case 'status': doStatus(msg); break;
case '--help': reply(msg, HELP_TEXT); break;
default: reply(msg, HELP_TEXT); break;
From 45f79b33f15fc4a3f9001f6320de5644583d0f0b Mon Sep 17 00:00:00 2001
From: Kenan Millet
Date: Mon, 15 Jun 2026 09:39:43 -0400
Subject: [PATCH 08/28] Mirror: use 'api-all' for API calls (bypasses global
excludes), 'all' for chat commands
---
Mirror/Mirror.js | 10 +++++++---
1 file changed, 7 insertions(+), 3 deletions(-)
diff --git a/Mirror/Mirror.js b/Mirror/Mirror.js
index 853b86019c..a1a89865f4 100644
--- a/Mirror/Mirror.js
+++ b/Mirror/Mirror.js
@@ -186,13 +186,17 @@ var Mirror = Mirror || (() => {
};
/**
- * Get the effective props for a link, accounting for 'all' and excludes.
+ * Get the effective props for a link, accounting for 'all'/'api-all' and excludes.
*/
const getEffectiveProps = (link) => {
if (link.props === 'all') {
var excludes = (link.excludes || []).concat(getGlobalExcludes());
return getKnownProps().filter(function(p) { return excludes.indexOf(p) === -1; });
}
+ if (link.props === 'api-all') {
+ var excludes = link.excludes || [];
+ return getKnownProps().filter(function(p) { return excludes.indexOf(p) === -1; });
+ }
return link.props;
};
@@ -574,12 +578,12 @@ var Mirror = Mirror || (() => {
const link = (ids, props, soft) => {
if (!ids || ids.length < 2) { log(SCRIPT_NAME + ': link requires at least 2 IDs.'); return null; }
- return createLink('link', props || getKnownProps(), ids, !!soft);
+ return createLink('link', props || 'api-all', ids, !!soft);
};
const chainLink = (ids, props) => {
if (!ids || ids.length < 2) { log(SCRIPT_NAME + ': chainLink requires at least 2 IDs.'); return null; }
- return createLink('chain', props || getKnownProps(), ids, true);
+ return createLink('chain', props || 'api-all', ids, true);
};
const unlink = (ids, props) => {
From f6133ea23722eb1d40c1316c58e4fc43b2837fb8 Mon Sep 17 00:00:00 2001
From: Kenan Millet
Date: Mon, 15 Jun 2026 09:53:38 -0400
Subject: [PATCH 09/28] Mirror: startup config drift warning, remove inline
reminder from config command
---
Mirror/Mirror.js | 35 +++++++++++++++++++++++++++++++++--
1 file changed, 33 insertions(+), 2 deletions(-)
diff --git a/Mirror/Mirror.js b/Mirror/Mirror.js
index a1a89865f4..5a6f187fcb 100644
--- a/Mirror/Mirror.js
+++ b/Mirror/Mirror.js
@@ -82,16 +82,29 @@ var Mirror = Mirror || (() => {
state[SCRIPT_NAME] = {
links: {},
chainedIds: {},
- knownProps: {}
+ knownProps: {},
+ configInitialized: false
};
}
if (!state[SCRIPT_NAME].chainedIds) state[SCRIPT_NAME].chainedIds = {};
if (!state[SCRIPT_NAME].knownProps) state[SCRIPT_NAME].knownProps = {};
if (!state[SCRIPT_NAME].globalExcludes) state[SCRIPT_NAME].globalExcludes = [];
+ // Seed global excludes from useroptions on first run
+ if (!state[SCRIPT_NAME].configInitialized && typeof globalconfig !== 'undefined' && globalconfig[SCRIPT_NAME]) {
+ var gc = globalconfig[SCRIPT_NAME];
+ if (gc['Global Excludes'] && gc['Global Excludes'].trim()) {
+ state[SCRIPT_NAME].globalExcludes = gc['Global Excludes'].split(',').map(function(s) { return s.trim(); }).filter(Boolean);
+ }
+ state[SCRIPT_NAME].configInitialized = true;
+ }
// Seed known props from ALL_PROPS
ALL_PROPS.forEach(function(p) { state[SCRIPT_NAME].knownProps[p] = true; });
};
+ const hasGlobalConfig = () => {
+ return typeof globalconfig !== 'undefined' && globalconfig[SCRIPT_NAME] && 'Global Excludes' in globalconfig[SCRIPT_NAME];
+ };
+
const getKnownProps = () => Object.keys(state[SCRIPT_NAME].knownProps);
// =========================================================================
@@ -407,7 +420,6 @@ var Mirror = Mirror || (() => {
const doConfig = (msg, args) => {
var s = state[SCRIPT_NAME];
if (args.length === 0) {
- // Show current config
reply(msg, 'Config', 'Global excludes: ' + (s.globalExcludes.length > 0 ? s.globalExcludes.join(', ') : '(none)'));
return;
}
@@ -603,6 +615,25 @@ var Mirror = Mirror || (() => {
const checkInstall = () => {
ensureState();
log('-=> ' + SCRIPT_NAME + ' v' + SCRIPT_VERSION + ' Initialized <=-');
+ checkConfigDrift();
+ };
+
+ const checkConfigDrift = () => {
+ if (!hasGlobalConfig()) return;
+ var gc = globalconfig[SCRIPT_NAME];
+ var gcExcludes = (gc['Global Excludes'] || '').split(',').map(function(s) { return s.trim(); }).filter(Boolean);
+ var stateExcludes = state[SCRIPT_NAME].globalExcludes || [];
+
+ // Compare
+ var gcSorted = gcExcludes.slice().sort().join(',');
+ var stateSorted = stateExcludes.slice().sort().join(',');
+ if (gcSorted !== stateSorted) {
+ sendChat(SCRIPT_NAME, '/w gm ⚠️ Mirror config drift: runtime global excludes (' +
+ (stateExcludes.length > 0 ? stateExcludes.join(', ') : 'none') +
+ ') differ from API Scripts page settings (' +
+ (gcExcludes.length > 0 ? gcExcludes.join(', ') : 'none') +
+ '). Use !mirror config to view/change, or update the API Scripts page to match.');
+ }
};
const registerEventHandlers = () => {
From b154d929479a02cf54546457bdfe8d847c6fc7ae Mon Sep 17 00:00:00 2001
From: Kenan Millet
Date: Mon, 15 Jun 2026 10:33:06 -0400
Subject: [PATCH 10/28] Mirror: null props = context-dependent (link scope for
align, 'all' for link/chain)
---
Mirror/Mirror.js | 39 ++++++++++++++++++++++++---------------
1 file changed, 24 insertions(+), 15 deletions(-)
diff --git a/Mirror/Mirror.js b/Mirror/Mirror.js
index 5a6f187fcb..d0139bfa3a 100644
--- a/Mirror/Mirror.js
+++ b/Mirror/Mirror.js
@@ -161,11 +161,12 @@ var Mirror = Mirror || (() => {
ids = ids.filter(function(id, i) { return ids.indexOf(id) === i; }); // dedupe
// Determine if using 'all' or specific props
+ // null means "no props specified" (let the caller decide context-dependent behavior)
var props;
if (resolved.props.length === 0) {
- props = 'all'; // default: all
+ props = null; // no props specified
} else if (resolved.props.length === getKnownProps().length) {
- props = 'all'; // explicit 'all' group resolved to full list
+ props = 'all'; // explicit 'all' group
} else {
props = resolved.props;
}
@@ -330,16 +331,20 @@ var Mirror = Mirror || (() => {
const doLink = (msg, args) => {
var parsed = parseCommand(msg, args);
if (parsed.ids.length < 2) { reply(msg, 'Error', 'Link requires at least 2 tokens.'); return; }
- createLink('link', parsed.props, parsed.ids, parsed.soft, parsed.excludes);
- if (parsed.align) alignTokens(parsed.ids, parsed.props === 'all' ? getKnownProps().filter(function(p) { return parsed.excludes.indexOf(p) === -1; }) : parsed.props);
- var propCount = parsed.props === 'all' ? 'all' : parsed.props.length;
+ var linkProps = parsed.props || 'all'; // null = default to all
+ createLink('link', linkProps, parsed.ids, parsed.soft, parsed.excludes);
+ if (parsed.align) {
+ var alignProps = linkProps === 'all' ? getKnownProps().filter(function(p) { return parsed.excludes.indexOf(p) === -1; }) : linkProps;
+ alignTokens(parsed.ids, alignProps);
+ }
+ var propCount = linkProps === 'all' ? 'all' : linkProps.length;
reply(msg, 'Link', 'Linked ' + parsed.ids.length + ' tokens (' + propCount + ' props' + (parsed.soft ? ', soft' : ', hard-lock') + (parsed.excludes.length ? ', ' + parsed.excludes.length + ' excluded' : '') + (parsed.align ? ', aligned' : '') + ').');
};
const doUnlink = (msg, args) => {
var parsed = parseCommand(msg, args);
if (parsed.ids.length === 0) { reply(msg, 'Error', 'Select or specify token(s).'); return; }
- var hasSpecificProps = parsed.props !== 'all';
+ var hasSpecificProps = parsed.props !== null && parsed.props !== 'all';
var processed = 0;
parsed.ids.forEach(function(id) {
findLinksForToken(id).forEach(function(entry) {
@@ -365,6 +370,7 @@ var Mirror = Mirror || (() => {
const doChain = (msg, args) => {
var parsed = parseCommand(msg, args);
if (parsed.ids.length < 2) { reply(msg, 'Error', 'Chain requires at least 2 tokens.'); return; }
+ var linkProps = parsed.props || 'all'; // null = default to all
// Check if tokens are already in an existing chain — if so, re-include props
var existingChain = null;
@@ -373,19 +379,19 @@ var Mirror = Mirror || (() => {
if (links[i].link.mode === 'chain') { existingChain = links[i]; break; }
}
- if (existingChain && parsed.props !== 'all') {
+ if (existingChain && Array.isArray(linkProps)) {
// Re-include: remove specified props from excludes
existingChain.link.excludes = (existingChain.link.excludes || []).filter(function(p) {
- return parsed.props.indexOf(p) === -1;
+ return linkProps.indexOf(p) === -1;
});
- reply(msg, 'Chain', 'Re-included ' + parsed.props.length + ' prop(s) in existing chain.');
+ reply(msg, 'Chain', 'Re-included ' + linkProps.length + ' prop(s) in existing chain.');
} else {
- createLink('chain', parsed.props, parsed.ids, true, parsed.excludes);
+ createLink('chain', linkProps, parsed.ids, true, parsed.excludes);
if (parsed.align) {
- var alignProps = parsed.props === 'all' ? getKnownProps().filter(function(p) { return parsed.excludes.indexOf(p) === -1; }) : parsed.props;
+ var alignProps = linkProps === 'all' ? getKnownProps().filter(function(p) { return parsed.excludes.indexOf(p) === -1; }) : linkProps;
alignTokens(parsed.ids, alignProps);
}
- var propCount = parsed.props === 'all' ? 'all' : parsed.props.length;
+ var propCount = linkProps === 'all' ? 'all' : linkProps.length;
reply(msg, 'Chain', 'Chain-linked ' + parsed.ids.length + ' tokens (' + propCount + ' props' + (parsed.excludes.length ? ', ' + parsed.excludes.length + ' excluded' : '') + (parsed.align ? ', aligned' : '') + ').');
}
};
@@ -393,7 +399,7 @@ var Mirror = Mirror || (() => {
const doUnchain = (msg, args) => {
var parsed = parseCommand(msg, args);
if (parsed.ids.length === 0) { reply(msg, 'Error', 'Select or specify token(s).'); return; }
- var hasSpecificProps = parsed.props !== 'all';
+ var hasSpecificProps = parsed.props !== null && parsed.props !== 'all';
var processed = 0;
parsed.ids.forEach(function(id) {
findLinksForToken(id).forEach(function(entry) {
@@ -463,7 +469,9 @@ var Mirror = Mirror || (() => {
var links = findLinksForToken(id);
links.forEach(function(entry) {
var link = entry.link;
- var props = parsed.props;
+ // null = use link's scope; 'all' or array = explicit
+ var props = parsed.props === null ? getEffectiveProps(link) :
+ parsed.props === 'all' ? getKnownProps() : parsed.props;
if (link.mode === 'chain') {
// Align to the first selected/passed id that is in this chain
var sourceId = parsed.ids.find(function(pid) { return link.ids.indexOf(pid) !== -1; });
@@ -510,8 +518,9 @@ var Mirror = Mirror || (() => {
var sourceId = parsed.ids[0];
var source = getObj('graphic', sourceId);
if (source) {
+ var alignProps = parsed.props === null || parsed.props === 'all' ? getKnownProps() : parsed.props;
var updates = {};
- parsed.props.forEach(function(p) { updates[p] = source.get(p); });
+ alignProps.forEach(function(p) { updates[p] = source.get(p); });
parsed.ids.slice(1).forEach(function(id) {
var links = findLinksForToken(id);
if (links.length === 0) {
From 9c27899367bb80d5496db63d153dea5ea65072f5 Mon Sep 17 00:00:00 2001
From: Kenan Millet
Date: Mon, 15 Jun 2026 10:49:00 -0400
Subject: [PATCH 11/28] Mirror: unlink removes tokens from chains, whispers
error for prop-specific unlink on chains
---
Mirror/Mirror.js | 44 +++++++++++++++++++++++++++++++-------------
1 file changed, 31 insertions(+), 13 deletions(-)
diff --git a/Mirror/Mirror.js b/Mirror/Mirror.js
index d0139bfa3a..a3cbcb0242 100644
--- a/Mirror/Mirror.js
+++ b/Mirror/Mirror.js
@@ -346,25 +346,43 @@ var Mirror = Mirror || (() => {
if (parsed.ids.length === 0) { reply(msg, 'Error', 'Select or specify token(s).'); return; }
var hasSpecificProps = parsed.props !== null && parsed.props !== 'all';
var processed = 0;
+ var errors = [];
parsed.ids.forEach(function(id) {
findLinksForToken(id).forEach(function(entry) {
- if (entry.link.mode !== 'link') return;
- if (!hasSpecificProps) {
- // No props specified: remove entire link
- removePropsFromLink(entry.id, null);
- } else if (entry.link.props === 'all') {
- // Link uses 'all': add props to excludes
- parsed.props.forEach(function(p) {
- if (entry.link.excludes.indexOf(p) === -1) entry.link.excludes.push(p);
- });
+ if (entry.link.mode === 'chain') {
+ if (hasSpecificProps) {
+ errors.push((getObj('graphic', id) || {get:function(){return id;}}).get('name') || id);
+ return;
+ }
+ // Remove this token from the chain
+ var link = entry.link;
+ link.ids = link.ids.filter(function(tid) { return tid !== id; });
+ // Rebuild chainedIds for removed token
+ rebuildChainedIds(id, entry.id);
+ // If chain has fewer than 2 members, destroy it
+ if (link.ids.length < 2) {
+ link.ids.forEach(function(tid) { rebuildChainedIds(tid, entry.id); });
+ delete state[SCRIPT_NAME].links[entry.id];
+ }
+ processed++;
} else {
- // Link uses specific props: remove them
- removePropsFromLink(entry.id, parsed.props);
+ // Non-chain: existing behavior
+ if (!hasSpecificProps) {
+ removePropsFromLink(entry.id, null);
+ } else if (entry.link.props === 'all' || entry.link.props === 'api-all') {
+ parsed.props.forEach(function(p) {
+ if (entry.link.excludes.indexOf(p) === -1) entry.link.excludes.push(p);
+ });
+ } else {
+ removePropsFromLink(entry.id, parsed.props);
+ }
+ processed++;
}
- processed++;
});
});
- reply(msg, 'Unlink', 'Processed ' + processed + ' link(s).');
+ var out = 'Processed ' + processed + ' link(s).';
+ if (errors.length > 0) out += '
Cannot unlink specific props from chain members: ' + errors.join(', ') + '. Use !mirror unchain [props] instead.';
+ reply(msg, 'Unlink', out);
};
const doChain = (msg, args) => {
From 48de8ed5c121c13348f61c7ab9fa35e8dafbf272 Mon Sep 17 00:00:00 2001
From: Kenan Millet
Date: Mon, 15 Jun 2026 10:53:30 -0400
Subject: [PATCH 12/28] Mirror: link/chain add props to existing links instead
of creating new ones
---
Mirror/Mirror.js | 54 ++++++++++++++++++++++++++++++++++++++----------
1 file changed, 43 insertions(+), 11 deletions(-)
diff --git a/Mirror/Mirror.js b/Mirror/Mirror.js
index a3cbcb0242..325d60d6d9 100644
--- a/Mirror/Mirror.js
+++ b/Mirror/Mirror.js
@@ -332,13 +332,36 @@ var Mirror = Mirror || (() => {
var parsed = parseCommand(msg, args);
if (parsed.ids.length < 2) { reply(msg, 'Error', 'Link requires at least 2 tokens.'); return; }
var linkProps = parsed.props || 'all'; // null = default to all
- createLink('link', linkProps, parsed.ids, parsed.soft, parsed.excludes);
- if (parsed.align) {
- var alignProps = linkProps === 'all' ? getKnownProps().filter(function(p) { return parsed.excludes.indexOf(p) === -1; }) : linkProps;
- alignTokens(parsed.ids, alignProps);
+
+ // Check if source token already has an existing link
+ var existingLink = null;
+ var links = findLinksForToken(parsed.ids[0]);
+ for (var i = 0; i < links.length; i++) {
+ if (links[i].link.mode === 'link' && links[i].link.ids[0] === parsed.ids[0]) { existingLink = links[i]; break; }
+ }
+
+ if (existingLink && Array.isArray(linkProps)) {
+ var link = existingLink.link;
+ if (link.props === 'all' || link.props === 'api-all') {
+ link.excludes = (link.excludes || []).filter(function(p) {
+ return linkProps.indexOf(p) === -1;
+ });
+ reply(msg, 'Link', 'Re-included ' + linkProps.length + ' prop(s) in existing link.');
+ } else {
+ linkProps.forEach(function(p) {
+ if (link.props.indexOf(p) === -1) link.props.push(p);
+ });
+ reply(msg, 'Link', 'Added ' + linkProps.length + ' prop(s) to existing link (' + link.props.length + ' total).');
+ }
+ } else {
+ createLink('link', linkProps, parsed.ids, parsed.soft, parsed.excludes);
+ if (parsed.align) {
+ var alignProps = linkProps === 'all' ? getKnownProps().filter(function(p) { return parsed.excludes.indexOf(p) === -1; }) : linkProps;
+ alignTokens(parsed.ids, alignProps);
+ }
+ var propCount = linkProps === 'all' ? 'all' : linkProps.length;
+ reply(msg, 'Link', 'Linked ' + parsed.ids.length + ' tokens (' + propCount + ' props' + (parsed.soft ? ', soft' : ', hard-lock') + (parsed.excludes.length ? ', ' + parsed.excludes.length + ' excluded' : '') + (parsed.align ? ', aligned' : '') + ').');
}
- var propCount = linkProps === 'all' ? 'all' : linkProps.length;
- reply(msg, 'Link', 'Linked ' + parsed.ids.length + ' tokens (' + propCount + ' props' + (parsed.soft ? ', soft' : ', hard-lock') + (parsed.excludes.length ? ', ' + parsed.excludes.length + ' excluded' : '') + (parsed.align ? ', aligned' : '') + ').');
};
const doUnlink = (msg, args) => {
@@ -398,11 +421,20 @@ var Mirror = Mirror || (() => {
}
if (existingChain && Array.isArray(linkProps)) {
- // Re-include: remove specified props from excludes
- existingChain.link.excludes = (existingChain.link.excludes || []).filter(function(p) {
- return linkProps.indexOf(p) === -1;
- });
- reply(msg, 'Chain', 'Re-included ' + linkProps.length + ' prop(s) in existing chain.');
+ var link = existingChain.link;
+ if (link.props === 'all' || link.props === 'api-all') {
+ // Re-include: remove specified props from excludes
+ link.excludes = (link.excludes || []).filter(function(p) {
+ return linkProps.indexOf(p) === -1;
+ });
+ reply(msg, 'Chain', 'Re-included ' + linkProps.length + ' prop(s) in existing chain.');
+ } else {
+ // Specific props list: add new props
+ linkProps.forEach(function(p) {
+ if (link.props.indexOf(p) === -1) link.props.push(p);
+ });
+ reply(msg, 'Chain', 'Added ' + linkProps.length + ' prop(s) to existing chain (' + link.props.length + ' total).');
+ }
} else {
createLink('chain', linkProps, parsed.ids, true, parsed.excludes);
if (parsed.align) {
From eb7c55c276741dd05760d8bf09ddaaa5ba8f43bb Mon Sep 17 00:00:00 2001
From: Kenan Millet
Date: Mon, 15 Jun 2026 11:09:40 -0400
Subject: [PATCH 13/28] Mirror: single-token support for link/chain/align,
--up/--down for ambiguous links
---
Mirror/Mirror.js | 98 ++++++++++++++++++++++++++++++++++++++++++------
1 file changed, 86 insertions(+), 12 deletions(-)
diff --git a/Mirror/Mirror.js b/Mirror/Mirror.js
index 325d60d6d9..98722045fe 100644
--- a/Mirror/Mirror.js
+++ b/Mirror/Mirror.js
@@ -329,15 +329,37 @@ var Mirror = Mirror || (() => {
// =========================================================================
const doLink = (msg, args) => {
+ var up = args.indexOf('--up') !== -1;
+ var down = args.indexOf('--down') !== -1;
+ args = args.filter(function(a) { return a !== '--up' && a !== '--down'; });
var parsed = parseCommand(msg, args);
- if (parsed.ids.length < 2) { reply(msg, 'Error', 'Link requires at least 2 tokens.'); return; }
- var linkProps = parsed.props || 'all'; // null = default to all
+ var linkProps = parsed.props || 'all';
- // Check if source token already has an existing link
+ // Check if token already has an existing link
var existingLink = null;
- var links = findLinksForToken(parsed.ids[0]);
- for (var i = 0; i < links.length; i++) {
- if (links[i].link.mode === 'link' && links[i].link.ids[0] === parsed.ids[0]) { existingLink = links[i]; break; }
+ if (parsed.ids.length >= 1) {
+ var links = findLinksForToken(parsed.ids[0]);
+ var asParent = links.filter(function(e) { return e.link.mode === 'link' && e.link.ids[0] === parsed.ids[0]; });
+ var asChild = links.filter(function(e) { return e.link.mode === 'link' && e.link.ids[0] !== parsed.ids[0]; });
+
+ if (parsed.ids.length === 1) {
+ if (asParent.length > 0 && asChild.length > 0 && !up && !down) {
+ reply(msg, 'Error', 'Token is both parent and child. Use --up (modify parent link) or --down (modify child link).');
+ return;
+ }
+ if (up && asChild.length > 0) existingLink = asChild[0];
+ else if (down && asParent.length > 0) existingLink = asParent[0];
+ else if (asParent.length > 0) existingLink = asParent[0];
+ else if (asChild.length > 0) existingLink = asChild[0];
+ } else {
+ // Multi-token: check if source has existing link as parent
+ if (asParent.length > 0) existingLink = asParent[0];
+ }
+ }
+
+ if (parsed.ids.length < 2 && !existingLink) {
+ reply(msg, 'Error', 'Link requires at least 2 tokens (or 1 token already in a link).');
+ return;
}
if (existingLink && Array.isArray(linkProps)) {
@@ -410,14 +432,20 @@ var Mirror = Mirror || (() => {
const doChain = (msg, args) => {
var parsed = parseCommand(msg, args);
- if (parsed.ids.length < 2) { reply(msg, 'Error', 'Chain requires at least 2 tokens.'); return; }
var linkProps = parsed.props || 'all'; // null = default to all
- // Check if tokens are already in an existing chain — if so, re-include props
+ // If only 1 token, check if it's already in a chain
var existingChain = null;
- var links = findLinksForToken(parsed.ids[0]);
- for (var i = 0; i < links.length; i++) {
- if (links[i].link.mode === 'chain') { existingChain = links[i]; break; }
+ if (parsed.ids.length >= 1) {
+ var links = findLinksForToken(parsed.ids[0]);
+ for (var i = 0; i < links.length; i++) {
+ if (links[i].link.mode === 'chain') { existingChain = links[i]; break; }
+ }
+ }
+
+ if (parsed.ids.length < 2 && !existingChain) {
+ reply(msg, 'Error', 'Chain requires at least 2 tokens (or 1 token already in a chain).');
+ return;
}
if (existingChain && Array.isArray(linkProps)) {
@@ -508,7 +536,53 @@ var Mirror = Mirror || (() => {
if (!linked && !unlinked) linked = true;
var parsed = parseCommand(msg, args);
- if (parsed.ids.length < 2) { reply(msg, 'Error', 'Align requires at least 2 tokens.'); return; }
+
+ // Allow single token if it's in a link/chain
+ if (parsed.ids.length === 1 && linked) {
+ var singleLinks = findLinksForToken(parsed.ids[0]);
+ if (singleLinks.length > 0) {
+ // For chains: align entire chain to this token
+ // For links: parent aligns children, child aligns to parent
+ var aligned = 0;
+ singleLinks.forEach(function(entry) {
+ var link = entry.link;
+ var props = parsed.props === null ? getEffectiveProps(link) :
+ parsed.props === 'all' ? getKnownProps() : parsed.props;
+ var source = getObj('graphic', parsed.ids[0]);
+ if (!source) return;
+ var updates = {};
+ props.forEach(function(p) { updates[p] = source.get(p); });
+
+ if (link.mode === 'chain') {
+ // Align entire chain to selected token
+ link.ids.forEach(function(tid) {
+ if (tid === parsed.ids[0]) return;
+ var t = getObj('graphic', tid);
+ if (t) { t.set(updates); aligned++; }
+ });
+ } else if (link.ids[0] === parsed.ids[0]) {
+ // Parent selected: align children
+ link.ids.slice(1).forEach(function(tid) {
+ var t = getObj('graphic', tid);
+ if (t) { t.set(updates); aligned++; }
+ });
+ } else {
+ // Child selected: align to parent
+ var parent = getObj('graphic', link.ids[0]);
+ if (parent) {
+ var parentUpdates = {};
+ props.forEach(function(p) { parentUpdates[p] = parent.get(p); });
+ source.set(parentUpdates);
+ aligned++;
+ }
+ }
+ });
+ reply(msg, 'Align', 'Aligned ' + aligned + ' token(s).');
+ return;
+ }
+ }
+
+ if (parsed.ids.length < 2) { reply(msg, 'Error', 'Align requires at least 2 tokens (or 1 token in a link/chain).'); return; }
var s = state[SCRIPT_NAME];
var aligned = 0;
From 24511265ce5c72a417309c7ea31ff623ad512f45 Mon Sep 17 00:00:00 2001
From: Kenan Millet
Date: Mon, 15 Jun 2026 11:12:26 -0400
Subject: [PATCH 14/28] Mirror: add --up/--down to align for parent/child
disambiguation
---
Mirror/Mirror.js | 68 ++++++++++++++++++++++++++++++++----------------
1 file changed, 46 insertions(+), 22 deletions(-)
diff --git a/Mirror/Mirror.js b/Mirror/Mirror.js
index 98722045fe..a852b852dd 100644
--- a/Mirror/Mirror.js
+++ b/Mirror/Mirror.js
@@ -531,7 +531,9 @@ var Mirror = Mirror || (() => {
const doAlign = (msg, args) => {
var linked = args.indexOf('--linked') !== -1;
var unlinked = args.indexOf('--unlinked') !== -1;
- args = args.filter(function(a) { return a !== '--linked' && a !== '--unlinked'; });
+ var up = args.indexOf('--up') !== -1;
+ var down = args.indexOf('--down') !== -1;
+ args = args.filter(function(a) { return a !== '--linked' && a !== '--unlinked' && a !== '--up' && a !== '--down'; });
// Default: --linked only
if (!linked && !unlinked) linked = true;
@@ -541,42 +543,64 @@ var Mirror = Mirror || (() => {
if (parsed.ids.length === 1 && linked) {
var singleLinks = findLinksForToken(parsed.ids[0]);
if (singleLinks.length > 0) {
- // For chains: align entire chain to this token
- // For links: parent aligns children, child aligns to parent
+ var asParent = singleLinks.filter(function(e) { return e.link.mode === 'link' && e.link.ids[0] === parsed.ids[0]; });
+ var asChild = singleLinks.filter(function(e) { return e.link.mode === 'link' && e.link.ids[0] !== parsed.ids[0]; });
+ var asChain = singleLinks.filter(function(e) { return e.link.mode === 'chain'; });
+
+ if (asParent.length > 0 && asChild.length > 0 && !up && !down) {
+ reply(msg, 'Error', 'Token is both parent and child. Use --up (align to parent) or --down (align children to me).');
+ return;
+ }
+
var aligned = 0;
- singleLinks.forEach(function(entry) {
+ var source = getObj('graphic', parsed.ids[0]);
+ if (!source) { reply(msg, 'Error', 'Token not found.'); return; }
+
+ // Handle chains
+ asChain.forEach(function(entry) {
var link = entry.link;
var props = parsed.props === null ? getEffectiveProps(link) :
parsed.props === 'all' ? getKnownProps() : parsed.props;
- var source = getObj('graphic', parsed.ids[0]);
- if (!source) return;
var updates = {};
props.forEach(function(p) { updates[p] = source.get(p); });
+ link.ids.forEach(function(tid) {
+ if (tid === parsed.ids[0]) return;
+ var t = getObj('graphic', tid);
+ if (t) { t.set(updates); aligned++; }
+ });
+ });
- if (link.mode === 'chain') {
- // Align entire chain to selected token
- link.ids.forEach(function(tid) {
- if (tid === parsed.ids[0]) return;
- var t = getObj('graphic', tid);
- if (t) { t.set(updates); aligned++; }
- });
- } else if (link.ids[0] === parsed.ids[0]) {
- // Parent selected: align children
+ // Handle one-way links
+ if (down || (!up && asParent.length > 0 && asChild.length === 0)) {
+ // Align children to me
+ asParent.forEach(function(entry) {
+ var link = entry.link;
+ var props = parsed.props === null ? getEffectiveProps(link) :
+ parsed.props === 'all' ? getKnownProps() : parsed.props;
+ var updates = {};
+ props.forEach(function(p) { updates[p] = source.get(p); });
link.ids.slice(1).forEach(function(tid) {
var t = getObj('graphic', tid);
if (t) { t.set(updates); aligned++; }
});
- } else {
- // Child selected: align to parent
+ });
+ }
+ if (up || (!down && asChild.length > 0 && asParent.length === 0)) {
+ // Align me to parent
+ asChild.forEach(function(entry) {
+ var link = entry.link;
+ var props = parsed.props === null ? getEffectiveProps(link) :
+ parsed.props === 'all' ? getKnownProps() : parsed.props;
var parent = getObj('graphic', link.ids[0]);
if (parent) {
- var parentUpdates = {};
- props.forEach(function(p) { parentUpdates[p] = parent.get(p); });
- source.set(parentUpdates);
+ var updates = {};
+ props.forEach(function(p) { updates[p] = parent.get(p); });
+ source.set(updates);
aligned++;
}
- }
- });
+ });
+ }
+
reply(msg, 'Align', 'Aligned ' + aligned + ' token(s).');
return;
}
From 6c301e97845d9ae7a0c4406ca4d9f254ffd6084c Mon Sep 17 00:00:00 2001
From: Kenan Millet
Date: Mon, 15 Jun 2026 11:27:18 -0400
Subject: [PATCH 15/28] Mirror: error on !mirror chain (no props) with existing
chain and no unchained tokens
---
Mirror/Mirror.js | 92 ++++++++++++++++++++++++++++++++----------------
1 file changed, 61 insertions(+), 31 deletions(-)
diff --git a/Mirror/Mirror.js b/Mirror/Mirror.js
index a852b852dd..ed2949ceec 100644
--- a/Mirror/Mirror.js
+++ b/Mirror/Mirror.js
@@ -432,45 +432,75 @@ var Mirror = Mirror || (() => {
const doChain = (msg, args) => {
var parsed = parseCommand(msg, args);
- var linkProps = parsed.props || 'all'; // null = default to all
+ var linkProps = parsed.props; // null = no props specified, 'all' = explicit all, [...] = specific
- // If only 1 token, check if it's already in a chain
- var existingChain = null;
- if (parsed.ids.length >= 1) {
- var links = findLinksForToken(parsed.ids[0]);
- for (var i = 0; i < links.length; i++) {
- if (links[i].link.mode === 'chain') { existingChain = links[i]; break; }
- }
- }
+ if (parsed.ids.length < 1) { reply(msg, 'Error', 'Select or specify at least one token.'); return; }
- if (parsed.ids.length < 2 && !existingChain) {
- reply(msg, 'Error', 'Chain requires at least 2 tokens (or 1 token already in a chain).');
- return;
- }
+ // Find which selected tokens are already in chains
+ var chainMap = {}; // linkId → entry
+ var unchainedIds = [];
+ parsed.ids.forEach(function(id) {
+ var links = findLinksForToken(id);
+ var inChain = links.find(function(e) { return e.link.mode === 'chain'; });
+ if (inChain) chainMap[inChain.id] = inChain;
+ else unchainedIds.push(id);
+ });
+ var existingChains = Object.values(chainMap);
- if (existingChain && Array.isArray(linkProps)) {
- var link = existingChain.link;
- if (link.props === 'all' || link.props === 'api-all') {
- // Re-include: remove specified props from excludes
- link.excludes = (link.excludes || []).filter(function(p) {
- return linkProps.indexOf(p) === -1;
+ if (linkProps === null) {
+ // No props specified: add unchained tokens to chain, or create new chain
+ if (existingChains.length > 1) {
+ reply(msg, 'Error', 'Selected tokens belong to multiple chains. Cannot merge.');
+ return;
+ }
+ if (existingChains.length === 1) {
+ // Add unchained tokens to the existing chain
+ if (unchainedIds.length === 0) {
+ reply(msg, 'Error', 'No unchained tokens to add. Use !mirror chain all to set all props, or specify props to add.');
+ return;
+ }
+ var chain = existingChains[0].link;
+ unchainedIds.forEach(function(id) {
+ if (chain.ids.indexOf(id) === -1) {
+ chain.ids.push(id);
+ state[SCRIPT_NAME].chainedIds[id] = true;
+ }
});
- reply(msg, 'Chain', 'Re-included ' + linkProps.length + ' prop(s) in existing chain.');
+ reply(msg, 'Chain', 'Added ' + unchainedIds.length + ' token(s) to existing chain (' + chain.ids.length + ' total).');
} else {
- // Specific props list: add new props
- linkProps.forEach(function(p) {
- if (link.props.indexOf(p) === -1) link.props.push(p);
- });
- reply(msg, 'Chain', 'Added ' + linkProps.length + ' prop(s) to existing chain (' + link.props.length + ' total).');
+ // No existing chains: create new chain with 'all'
+ if (parsed.ids.length < 2) { reply(msg, 'Error', 'Chain requires at least 2 tokens.'); return; }
+ createLink('chain', 'all', parsed.ids, true, parsed.excludes);
+ if (parsed.align) alignTokens(parsed.ids, getKnownProps().filter(function(p) { return parsed.excludes.indexOf(p) === -1; }));
+ reply(msg, 'Chain', 'Chain-linked ' + parsed.ids.length + ' tokens (all props' + (parsed.align ? ', aligned' : '') + ').');
}
} else {
- createLink('chain', linkProps, parsed.ids, true, parsed.excludes);
- if (parsed.align) {
- var alignProps = linkProps === 'all' ? getKnownProps().filter(function(p) { return parsed.excludes.indexOf(p) === -1; }) : linkProps;
- alignTokens(parsed.ids, alignProps);
+ // Props specified: modify existing chains or create new one
+ if (existingChains.length > 0) {
+ existingChains.forEach(function(entry) {
+ var link = entry.link;
+ var propsToApply = linkProps === 'all' ? null : linkProps;
+ if (propsToApply && (link.props === 'all' || link.props === 'api-all')) {
+ link.excludes = (link.excludes || []).filter(function(p) { return propsToApply.indexOf(p) === -1; });
+ } else if (propsToApply && Array.isArray(link.props)) {
+ propsToApply.forEach(function(p) { if (link.props.indexOf(p) === -1) link.props.push(p); });
+ }
+ });
+ var propCount = linkProps === 'all' ? 'all' : linkProps.length;
+ var msg2 = 'Updated ' + existingChains.length + ' chain(s) (' + propCount + ' props).';
+ if (unchainedIds.length > 0) msg2 += '
' + unchainedIds.length + ' unchained token(s) ignored (use no props to add them).';
+ reply(msg, 'Chain', msg2);
+ } else {
+ // No existing chains: create new
+ if (parsed.ids.length < 2) { reply(msg, 'Error', 'Chain requires at least 2 tokens.'); return; }
+ createLink('chain', linkProps, parsed.ids, true, parsed.excludes);
+ if (parsed.align) {
+ var alignProps = linkProps === 'all' ? getKnownProps().filter(function(p) { return parsed.excludes.indexOf(p) === -1; }) : linkProps;
+ alignTokens(parsed.ids, alignProps);
+ }
+ var propCount = linkProps === 'all' ? 'all' : linkProps.length;
+ reply(msg, 'Chain', 'Chain-linked ' + parsed.ids.length + ' tokens (' + propCount + ' props' + (parsed.excludes.length ? ', ' + parsed.excludes.length + ' excluded' : '') + (parsed.align ? ', aligned' : '') + ').');
}
- var propCount = linkProps === 'all' ? 'all' : linkProps.length;
- reply(msg, 'Chain', 'Chain-linked ' + parsed.ids.length + ' tokens (' + propCount + ' props' + (parsed.excludes.length ? ', ' + parsed.excludes.length + ' excluded' : '') + (parsed.align ? ', aligned' : '') + ').');
}
};
From 6c19083de1d099a69b05adc9cc098815492b24ed Mon Sep 17 00:00:00 2001
From: Kenan Millet
Date: Mon, 15 Jun 2026 11:38:11 -0400
Subject: [PATCH 16/28] =?UTF-8?q?Mirror:=20align=20application=20order=20f?=
=?UTF-8?q?ixed=20to=20up=20=E2=86=92=20chain=20=E2=86=92=20down?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
Mirror/Mirror.js | 77 ++++++++++++++++++++++++++++--------------------
1 file changed, 45 insertions(+), 32 deletions(-)
diff --git a/Mirror/Mirror.js b/Mirror/Mirror.js
index ed2949ceec..a4e9691761 100644
--- a/Mirror/Mirror.js
+++ b/Mirror/Mirror.js
@@ -563,13 +563,13 @@ var Mirror = Mirror || (() => {
var unlinked = args.indexOf('--unlinked') !== -1;
var up = args.indexOf('--up') !== -1;
var down = args.indexOf('--down') !== -1;
- args = args.filter(function(a) { return a !== '--linked' && a !== '--unlinked' && a !== '--up' && a !== '--down'; });
- // Default: --linked only
+ var chainFlag = args.indexOf('--chain') !== -1;
+ args = args.filter(function(a) { return a !== '--linked' && a !== '--unlinked' && a !== '--up' && a !== '--down' && a !== '--chain'; });
if (!linked && !unlinked) linked = true;
var parsed = parseCommand(msg, args);
- // Allow single token if it's in a link/chain
+ // Single-token align
if (parsed.ids.length === 1 && linked) {
var singleLinks = findLinksForToken(parsed.ids[0]);
if (singleLinks.length > 0) {
@@ -577,57 +577,70 @@ var Mirror = Mirror || (() => {
var asChild = singleLinks.filter(function(e) { return e.link.mode === 'link' && e.link.ids[0] !== parsed.ids[0]; });
var asChain = singleLinks.filter(function(e) { return e.link.mode === 'chain'; });
- if (asParent.length > 0 && asChild.length > 0 && !up && !down) {
- reply(msg, 'Error', 'Token is both parent and child. Use --up (align to parent) or --down (align children to me).');
+ var types = (asChain.length > 0 ? 1 : 0) + (asParent.length > 0 ? 1 : 0) + (asChild.length > 0 ? 1 : 0);
+ var hasFlags = up || down || chainFlag;
+
+ // If multiple relationship types and no flags, error
+ if (types > 1 && !hasFlags) {
+ var typeNames = [];
+ if (asChain.length > 0) typeNames.push('--chain');
+ if (asChild.length > 0) typeNames.push('--up');
+ if (asParent.length > 0) typeNames.push('--down');
+ reply(msg, 'Error', 'Token has multiple relationship types. Specify: ' + typeNames.join(', '));
return;
}
+ // Determine what to do
+ var doChainAlign = chainFlag || (!hasFlags && asChain.length > 0 && types === 1);
+ var doUp = up || (!hasFlags && asChild.length > 0 && types === 1);
+ var doDown = down || (!hasFlags && asParent.length > 0 && types === 1);
+
var aligned = 0;
var source = getObj('graphic', parsed.ids[0]);
if (!source) { reply(msg, 'Error', 'Token not found.'); return; }
- // Handle chains
- asChain.forEach(function(entry) {
- var link = entry.link;
- var props = parsed.props === null ? getEffectiveProps(link) :
- parsed.props === 'all' ? getKnownProps() : parsed.props;
- var updates = {};
- props.forEach(function(p) { updates[p] = source.get(p); });
- link.ids.forEach(function(tid) {
- if (tid === parsed.ids[0]) return;
- var t = getObj('graphic', tid);
- if (t) { t.set(updates); aligned++; }
+ // Application order: up → chain → down
+ if (doUp) {
+ asChild.forEach(function(entry) {
+ var link = entry.link;
+ var props = parsed.props === null ? getEffectiveProps(link) :
+ parsed.props === 'all' ? getKnownProps() : parsed.props;
+ var parent = getObj('graphic', link.ids[0]);
+ if (parent) {
+ var updates = {};
+ props.forEach(function(p) { updates[p] = parent.get(p); });
+ source.set(updates);
+ aligned++;
+ }
});
- });
+ }
- // Handle one-way links
- if (down || (!up && asParent.length > 0 && asChild.length === 0)) {
- // Align children to me
- asParent.forEach(function(entry) {
+ if (doChainAlign) {
+ asChain.forEach(function(entry) {
var link = entry.link;
var props = parsed.props === null ? getEffectiveProps(link) :
parsed.props === 'all' ? getKnownProps() : parsed.props;
var updates = {};
props.forEach(function(p) { updates[p] = source.get(p); });
- link.ids.slice(1).forEach(function(tid) {
+ link.ids.forEach(function(tid) {
+ if (tid === parsed.ids[0]) return;
var t = getObj('graphic', tid);
if (t) { t.set(updates); aligned++; }
});
});
}
- if (up || (!down && asChild.length > 0 && asParent.length === 0)) {
- // Align me to parent
- asChild.forEach(function(entry) {
+
+ if (doDown) {
+ asParent.forEach(function(entry) {
var link = entry.link;
var props = parsed.props === null ? getEffectiveProps(link) :
parsed.props === 'all' ? getKnownProps() : parsed.props;
- var parent = getObj('graphic', link.ids[0]);
- if (parent) {
- var updates = {};
- props.forEach(function(p) { updates[p] = parent.get(p); });
- source.set(updates);
- aligned++;
- }
+ var updates = {};
+ props.forEach(function(p) { updates[p] = source.get(p); });
+ link.ids.slice(1).forEach(function(tid) {
+ var t = getObj('graphic', tid);
+ if (t) { t.set(updates); aligned++; }
+ });
});
}
From 598b7804de8cb49acef8df2a0cc79c1a3f32dea4 Mon Sep 17 00:00:00 2001
From: Kenan Millet
Date: Mon, 15 Jun 2026 11:44:35 -0400
Subject: [PATCH 17/28] Mirror: recursive propagation with visited set for both
change events and align --down
---
Mirror/Mirror.js | 131 +++++++++++++++++++++++++++++++++--------------
1 file changed, 93 insertions(+), 38 deletions(-)
diff --git a/Mirror/Mirror.js b/Mirror/Mirror.js
index a4e9691761..fba3004e6c 100644
--- a/Mirror/Mirror.js
+++ b/Mirror/Mirror.js
@@ -263,6 +263,48 @@ var Mirror = Mirror || (() => {
var syncing = false;
+ /**
+ * Recursively propagate updates to targets and their children.
+ * visited prevents infinite loops in circular link structures.
+ */
+ const propagateUpdates = (tokenId, updates, visited) => {
+ var s = state[SCRIPT_NAME];
+ Object.values(s.links).forEach(function(link) {
+ var idx = link.ids.indexOf(tokenId);
+ if (idx === -1) return;
+
+ var effectiveProps = getEffectiveProps(link);
+ var relevantUpdates = {};
+ Object.keys(updates).forEach(function(p) {
+ if (effectiveProps.indexOf(p) !== -1) relevantUpdates[p] = updates[p];
+ });
+ if (Object.keys(relevantUpdates).length === 0) return;
+
+ if (link.mode === 'chain') {
+ link.ids.forEach(function(id) {
+ if (id === tokenId || visited.has(id)) return;
+ visited.add(id);
+ var target = getObj('graphic', id);
+ if (target) {
+ target.set(relevantUpdates);
+ propagateUpdates(id, relevantUpdates, visited);
+ }
+ });
+ } else if (idx === 0) {
+ // Source: propagate down to children
+ link.ids.slice(1).forEach(function(id) {
+ if (visited.has(id)) return;
+ visited.add(id);
+ var target = getObj('graphic', id);
+ if (target) {
+ target.set(relevantUpdates);
+ propagateUpdates(id, relevantUpdates, visited);
+ }
+ });
+ }
+ });
+ };
+
const onGraphicChanged = (obj, prev) => {
if (syncing) return;
var s = state[SCRIPT_NAME];
@@ -277,51 +319,53 @@ var Mirror = Mirror || (() => {
// Grow known props set with any discovered properties
changed.forEach(function(p) { s.knownProps[p] = true; });
+ syncing = true;
+ var visited = new Set([tokenId]);
+
Object.values(s.links).forEach(function(link) {
var idx = link.ids.indexOf(tokenId);
if (idx === -1) return;
- // Determine relevant changed props for this link
var effectiveProps = getEffectiveProps(link);
var relevantProps = changed.filter(function(p) { return effectiveProps.indexOf(p) !== -1; });
if (relevantProps.length === 0) return;
+ var updates = {};
+ relevantProps.forEach(function(p) { updates[p] = obj.get(p); });
+
if (link.mode === 'chain') {
- // Bidirectional: propagate to all others
- var updates = {};
- relevantProps.forEach(function(p) { updates[p] = obj.get(p); });
- syncing = true;
link.ids.forEach(function(id) {
- if (id === tokenId) return;
+ if (visited.has(id)) return;
+ visited.add(id);
var target = getObj('graphic', id);
- if (target) target.set(updates);
+ if (target) {
+ target.set(updates);
+ propagateUpdates(id, updates, visited);
+ }
});
- syncing = false;
- } else {
- // Unidirectional
- if (idx === 0) {
- // Source changed: propagate to targets
- var updates = {};
- relevantProps.forEach(function(p) { updates[p] = obj.get(p); });
- syncing = true;
- link.ids.slice(1).forEach(function(id) {
- var target = getObj('graphic', id);
- if (target) target.set(updates);
- });
- syncing = false;
- } else if (!link.soft) {
- // Hard lock: revert child to source value
- var source = getObj('graphic', link.ids[0]);
- if (source) {
- var revert = {};
- relevantProps.forEach(function(p) { revert[p] = source.get(p); });
- syncing = true;
- obj.set(revert);
- syncing = false;
+ } else if (idx === 0) {
+ // Source: propagate to children recursively
+ link.ids.slice(1).forEach(function(id) {
+ if (visited.has(id)) return;
+ visited.add(id);
+ var target = getObj('graphic', id);
+ if (target) {
+ target.set(updates);
+ propagateUpdates(id, updates, visited);
}
+ });
+ } else if (!link.soft) {
+ // Hard lock: revert child to source value
+ var source = getObj('graphic', link.ids[0]);
+ if (source) {
+ var revert = {};
+ relevantProps.forEach(function(p) { revert[p] = source.get(p); });
+ obj.set(revert);
}
}
});
+
+ syncing = false;
};
// =========================================================================
@@ -631,17 +675,28 @@ var Mirror = Mirror || (() => {
}
if (doDown) {
- asParent.forEach(function(entry) {
- var link = entry.link;
- var props = parsed.props === null ? getEffectiveProps(link) :
- parsed.props === 'all' ? getKnownProps() : parsed.props;
+ var downVisited = new Set([parsed.ids[0]]);
+ var alignDown = function(parentId, props) {
+ var s = state[SCRIPT_NAME];
+ var parentObj = getObj('graphic', parentId);
+ if (!parentObj) return;
var updates = {};
- props.forEach(function(p) { updates[p] = source.get(p); });
- link.ids.slice(1).forEach(function(tid) {
- var t = getObj('graphic', tid);
- if (t) { t.set(updates); aligned++; }
+ props.forEach(function(p) { updates[p] = parentObj.get(p); });
+ Object.values(s.links).forEach(function(link) {
+ if (link.mode !== 'link' || link.ids[0] !== parentId) return;
+ var linkProps = parsed.props === null ? getEffectiveProps(link) :
+ parsed.props === 'all' ? getKnownProps() : parsed.props;
+ var linkUpdates = {};
+ linkProps.forEach(function(p) { linkUpdates[p] = parentObj.get(p); });
+ link.ids.slice(1).forEach(function(tid) {
+ if (downVisited.has(tid)) return;
+ downVisited.add(tid);
+ var t = getObj('graphic', tid);
+ if (t) { t.set(linkUpdates); aligned++; alignDown(tid, linkProps); }
+ });
});
- });
+ };
+ alignDown(parsed.ids[0], parsed.props === null ? getKnownProps() : (parsed.props === 'all' ? getKnownProps() : parsed.props));
}
reply(msg, 'Align', 'Aligned ' + aligned + ' token(s).');
From bccf0a8641e9c195f25b5e32a68dc04b004f32b3 Mon Sep 17 00:00:00 2001
From: Kenan Millet
Date: Mon, 15 Jun 2026 11:55:31 -0400
Subject: [PATCH 18/28] Mirror: simplify align to up+cascade model, remove
--chain flag, always cascade recursively
---
Mirror/Mirror.js | 115 ++++++++++++++++++++---------------------------
1 file changed, 48 insertions(+), 67 deletions(-)
diff --git a/Mirror/Mirror.js b/Mirror/Mirror.js
index fba3004e6c..edd6e4dc38 100644
--- a/Mirror/Mirror.js
+++ b/Mirror/Mirror.js
@@ -607,9 +607,10 @@ var Mirror = Mirror || (() => {
var unlinked = args.indexOf('--unlinked') !== -1;
var up = args.indexOf('--up') !== -1;
var down = args.indexOf('--down') !== -1;
- var chainFlag = args.indexOf('--chain') !== -1;
args = args.filter(function(a) { return a !== '--linked' && a !== '--unlinked' && a !== '--up' && a !== '--down' && a !== '--chain'; });
if (!linked && !unlinked) linked = true;
+ // --up takes precedence; --up --down is same as --up
+ if (up) down = false;
var parsed = parseCommand(msg, args);
@@ -617,87 +618,67 @@ var Mirror = Mirror || (() => {
if (parsed.ids.length === 1 && linked) {
var singleLinks = findLinksForToken(parsed.ids[0]);
if (singleLinks.length > 0) {
- var asParent = singleLinks.filter(function(e) { return e.link.mode === 'link' && e.link.ids[0] === parsed.ids[0]; });
var asChild = singleLinks.filter(function(e) { return e.link.mode === 'link' && e.link.ids[0] !== parsed.ids[0]; });
- var asChain = singleLinks.filter(function(e) { return e.link.mode === 'chain'; });
-
- var types = (asChain.length > 0 ? 1 : 0) + (asParent.length > 0 ? 1 : 0) + (asChild.length > 0 ? 1 : 0);
- var hasFlags = up || down || chainFlag;
-
- // If multiple relationship types and no flags, error
- if (types > 1 && !hasFlags) {
- var typeNames = [];
- if (asChain.length > 0) typeNames.push('--chain');
- if (asChild.length > 0) typeNames.push('--up');
- if (asParent.length > 0) typeNames.push('--down');
- reply(msg, 'Error', 'Token has multiple relationship types. Specify: ' + typeNames.join(', '));
+ var isParentOrChain = singleLinks.some(function(e) { return e.link.mode === 'chain' || (e.link.mode === 'link' && e.link.ids[0] === parsed.ids[0]); });
+ var isChild = asChild.length > 0;
+
+ // Ambiguous: both parent/chain AND child, no flags
+ if (isParentOrChain && isChild && !up && !down) {
+ reply(msg, 'Error', 'Token is both parent/chain and child. Use --up (align to parent then cascade) or --down (cascade from current value).');
return;
}
- // Determine what to do
- var doChainAlign = chainFlag || (!hasFlags && asChain.length > 0 && types === 1);
- var doUp = up || (!hasFlags && asChild.length > 0 && types === 1);
- var doDown = down || (!hasFlags && asParent.length > 0 && types === 1);
-
- var aligned = 0;
var source = getObj('graphic', parsed.ids[0]);
if (!source) { reply(msg, 'Error', 'Token not found.'); return; }
+ var aligned = 0;
- // Application order: up → chain → down
- if (doUp) {
- asChild.forEach(function(entry) {
- var link = entry.link;
- var props = parsed.props === null ? getEffectiveProps(link) :
- parsed.props === 'all' ? getKnownProps() : parsed.props;
- var parent = getObj('graphic', link.ids[0]);
- if (parent) {
- var updates = {};
- props.forEach(function(p) { updates[p] = parent.get(p); });
- source.set(updates);
- aligned++;
- }
- });
+ // Step 1: If --up (or unambiguous child), align self to parent
+ var doUp = up || (!down && isChild && !isParentOrChain);
+ if (doUp && asChild.length > 0) {
+ var parentLink = asChild[0].link;
+ var props = parsed.props === null ? getEffectiveProps(parentLink) :
+ parsed.props === 'all' ? getKnownProps() : parsed.props;
+ var parent = getObj('graphic', parentLink.ids[0]);
+ if (parent) {
+ var updates = {};
+ props.forEach(function(p) { updates[p] = parent.get(p); });
+ source.set(updates);
+ aligned++;
+ }
}
- if (doChainAlign) {
- asChain.forEach(function(entry) {
- var link = entry.link;
- var props = parsed.props === null ? getEffectiveProps(link) :
+ // Step 2: Cascade from self to chain + children recursively
+ var cascadeVisited = new Set([parsed.ids[0]]);
+ var cascadeFrom = function(tokenId) {
+ var tokenObj = getObj('graphic', tokenId);
+ if (!tokenObj) return;
+ var s = state[SCRIPT_NAME];
+ Object.values(s.links).forEach(function(link) {
+ var idx = link.ids.indexOf(tokenId);
+ if (idx === -1) return;
+ var linkProps = parsed.props === null ? getEffectiveProps(link) :
parsed.props === 'all' ? getKnownProps() : parsed.props;
var updates = {};
- props.forEach(function(p) { updates[p] = source.get(p); });
- link.ids.forEach(function(tid) {
- if (tid === parsed.ids[0]) return;
- var t = getObj('graphic', tid);
- if (t) { t.set(updates); aligned++; }
- });
- });
- }
+ linkProps.forEach(function(p) { updates[p] = tokenObj.get(p); });
- if (doDown) {
- var downVisited = new Set([parsed.ids[0]]);
- var alignDown = function(parentId, props) {
- var s = state[SCRIPT_NAME];
- var parentObj = getObj('graphic', parentId);
- if (!parentObj) return;
- var updates = {};
- props.forEach(function(p) { updates[p] = parentObj.get(p); });
- Object.values(s.links).forEach(function(link) {
- if (link.mode !== 'link' || link.ids[0] !== parentId) return;
- var linkProps = parsed.props === null ? getEffectiveProps(link) :
- parsed.props === 'all' ? getKnownProps() : parsed.props;
- var linkUpdates = {};
- linkProps.forEach(function(p) { linkUpdates[p] = parentObj.get(p); });
+ if (link.mode === 'chain') {
+ link.ids.forEach(function(tid) {
+ if (cascadeVisited.has(tid)) return;
+ cascadeVisited.add(tid);
+ var t = getObj('graphic', tid);
+ if (t) { t.set(updates); aligned++; cascadeFrom(tid); }
+ });
+ } else if (idx === 0) {
link.ids.slice(1).forEach(function(tid) {
- if (downVisited.has(tid)) return;
- downVisited.add(tid);
+ if (cascadeVisited.has(tid)) return;
+ cascadeVisited.add(tid);
var t = getObj('graphic', tid);
- if (t) { t.set(linkUpdates); aligned++; alignDown(tid, linkProps); }
+ if (t) { t.set(updates); aligned++; cascadeFrom(tid); }
});
- });
- };
- alignDown(parsed.ids[0], parsed.props === null ? getKnownProps() : (parsed.props === 'all' ? getKnownProps() : parsed.props));
- }
+ }
+ });
+ };
+ cascadeFrom(parsed.ids[0]);
reply(msg, 'Align', 'Aligned ' + aligned + ' token(s).');
return;
From 89dcd4d35486f09fea0405951976b4280e7114b8 Mon Sep 17 00:00:00 2001
From: Kenan Millet
Date: Mon, 15 Jun 2026 12:03:20 -0400
Subject: [PATCH 19/28] Mirror: add --if-linked flag to align (intersect
requested props with link scope)
---
Mirror/Mirror.js | 7 +++++--
1 file changed, 5 insertions(+), 2 deletions(-)
diff --git a/Mirror/Mirror.js b/Mirror/Mirror.js
index edd6e4dc38..b410325993 100644
--- a/Mirror/Mirror.js
+++ b/Mirror/Mirror.js
@@ -607,7 +607,8 @@ var Mirror = Mirror || (() => {
var unlinked = args.indexOf('--unlinked') !== -1;
var up = args.indexOf('--up') !== -1;
var down = args.indexOf('--down') !== -1;
- args = args.filter(function(a) { return a !== '--linked' && a !== '--unlinked' && a !== '--up' && a !== '--down' && a !== '--chain'; });
+ var ifLinked = args.indexOf('--if-linked') !== -1;
+ args = args.filter(function(a) { return a !== '--linked' && a !== '--unlinked' && a !== '--up' && a !== '--down' && a !== '--chain' && a !== '--if-linked'; });
if (!linked && !unlinked) linked = true;
// --up takes precedence; --up --down is same as --up
if (up) down = false;
@@ -656,8 +657,10 @@ var Mirror = Mirror || (() => {
Object.values(s.links).forEach(function(link) {
var idx = link.ids.indexOf(tokenId);
if (idx === -1) return;
- var linkProps = parsed.props === null ? getEffectiveProps(link) :
+ var requestedProps = parsed.props === null ? getEffectiveProps(link) :
parsed.props === 'all' ? getKnownProps() : parsed.props;
+ // --if-linked: intersect with link's effective props
+ var linkProps = ifLinked ? requestedProps.filter(function(p) { return getEffectiveProps(link).indexOf(p) !== -1; }) : requestedProps;
var updates = {};
linkProps.forEach(function(p) { updates[p] = tokenObj.get(p); });
From 2a3d9a737e5bb4a4fe9fd4ac631084449affb603 Mon Sep 17 00:00:00 2001
From: Kenan Millet
Date: Mon, 15 Jun 2026 12:07:13 -0400
Subject: [PATCH 20/28] =?UTF-8?q?Mirror:=20full=20public=20API=20=E2=80=94?=
=?UTF-8?q?=20link,=20chainLink,=20unlink,=20unchain,=20addToChain,=20remo?=
=?UTF-8?q?veFromChain,=20align,=20getLinks,=20config?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
Mirror/Mirror.js | 154 +++++++++++++++++++++++++++++++++++++++++++----
1 file changed, 142 insertions(+), 12 deletions(-)
diff --git a/Mirror/Mirror.js b/Mirror/Mirror.js
index b410325993..4e04656056 100644
--- a/Mirror/Mirror.js
+++ b/Mirror/Mirror.js
@@ -827,26 +827,148 @@ var Mirror = Mirror || (() => {
// Public API
// =========================================================================
- const link = (ids, props, soft) => {
+ /** Create a unidirectional link. props: array or 'api-all'. soft: bool. */
+ const apiLink = (ids, props, soft, excludes) => {
if (!ids || ids.length < 2) { log(SCRIPT_NAME + ': link requires at least 2 IDs.'); return null; }
- return createLink('link', props || 'api-all', ids, !!soft);
+ return createLink('link', props || 'api-all', ids, !!soft, excludes);
};
- const chainLink = (ids, props) => {
+ /** Create a bidirectional chain link. */
+ const apiChainLink = (ids, props, excludes) => {
if (!ids || ids.length < 2) { log(SCRIPT_NAME + ': chainLink requires at least 2 IDs.'); return null; }
- return createLink('chain', props || 'api-all', ids, true);
+ return createLink('chain', props || 'api-all', ids, true, excludes);
};
- const unlink = (ids, props) => {
- var s = state[SCRIPT_NAME];
- var propsToRemove = (props && props.length > 0) ? props : null;
+ /** Remove links for given token IDs. props: array to remove specific, null to remove all. */
+ const apiUnlink = (ids, props) => {
ids.forEach(function(id) {
findLinksForToken(id).forEach(function(entry) {
- removePropsFromLink(entry.id, propsToRemove);
+ if (entry.link.mode === 'chain') {
+ // Remove token from chain
+ entry.link.ids = entry.link.ids.filter(function(tid) { return tid !== id; });
+ rebuildChainedIds(id, entry.id);
+ if (entry.link.ids.length < 2) {
+ entry.link.ids.forEach(function(tid) { rebuildChainedIds(tid, entry.id); });
+ delete state[SCRIPT_NAME].links[entry.id];
+ }
+ } else {
+ if (!props || props.length === 0) removePropsFromLink(entry.id, null);
+ else if (entry.link.props === 'all' || entry.link.props === 'api-all') {
+ props.forEach(function(p) { if (entry.link.excludes.indexOf(p) === -1) entry.link.excludes.push(p); });
+ } else {
+ removePropsFromLink(entry.id, props);
+ }
+ }
});
});
};
+ /** Remove chain for given token IDs. props: add to excludes. null: destroy chain. */
+ const apiUnchain = (ids, props) => {
+ ids.forEach(function(id) {
+ findLinksForToken(id).forEach(function(entry) {
+ if (entry.link.mode !== 'chain') return;
+ if (!props || props.length === 0) {
+ removePropsFromLink(entry.id, null);
+ } else if (entry.link.props === 'all' || entry.link.props === 'api-all') {
+ props.forEach(function(p) { if (entry.link.excludes.indexOf(p) === -1) entry.link.excludes.push(p); });
+ } else {
+ removePropsFromLink(entry.id, props);
+ }
+ });
+ });
+ };
+
+ /** Add tokens to an existing chain. existingId: any ID in the chain. newIds: IDs to add. */
+ const apiAddToChain = (existingId, newIds) => {
+ var links = findLinksForToken(existingId);
+ var chainEntry = links.find(function(e) { return e.link.mode === 'chain'; });
+ if (!chainEntry) { log(SCRIPT_NAME + ': addToChain — token is not in a chain.'); return; }
+ newIds.forEach(function(id) {
+ if (chainEntry.link.ids.indexOf(id) === -1) {
+ chainEntry.link.ids.push(id);
+ state[SCRIPT_NAME].chainedIds[id] = true;
+ }
+ });
+ };
+
+ /** Remove a token from its chain without destroying it. */
+ const apiRemoveFromChain = (tokenId) => {
+ var links = findLinksForToken(tokenId);
+ links.forEach(function(entry) {
+ if (entry.link.mode !== 'chain') return;
+ entry.link.ids = entry.link.ids.filter(function(id) { return id !== tokenId; });
+ rebuildChainedIds(tokenId, entry.id);
+ if (entry.link.ids.length < 2) {
+ entry.link.ids.forEach(function(id) { rebuildChainedIds(id, entry.id); });
+ delete state[SCRIPT_NAME].links[entry.id];
+ }
+ });
+ };
+
+ /** Align tokens. sourceId's values cascade to chain/children. options: { up, ifLinked, props } */
+ const apiAlign = (sourceId, options) => {
+ options = options || {};
+ var source = getObj('graphic', sourceId);
+ if (!source) { log(SCRIPT_NAME + ': align — source not found.'); return; }
+ var singleLinks = findLinksForToken(sourceId);
+ if (singleLinks.length === 0) return;
+
+ // If up: align to parent first
+ if (options.up) {
+ var asChild = singleLinks.filter(function(e) { return e.link.mode === 'link' && e.link.ids[0] !== sourceId; });
+ if (asChild.length > 0) {
+ var parentLink = asChild[0].link;
+ var props = options.props || getEffectiveProps(parentLink);
+ var parent = getObj('graphic', parentLink.ids[0]);
+ if (parent) {
+ var updates = {};
+ props.forEach(function(p) { updates[p] = parent.get(p); });
+ source.set(updates);
+ }
+ }
+ }
+
+ // Cascade from source
+ var visited = new Set([sourceId]);
+ var cascadeFrom = function(tokenId) {
+ var tokenObj = getObj('graphic', tokenId);
+ if (!tokenObj) return;
+ Object.values(state[SCRIPT_NAME].links).forEach(function(link) {
+ var idx = link.ids.indexOf(tokenId);
+ if (idx === -1) return;
+ var requestedProps = options.props || getEffectiveProps(link);
+ var linkProps = options.ifLinked ? requestedProps.filter(function(p) { return getEffectiveProps(link).indexOf(p) !== -1; }) : requestedProps;
+ if (linkProps.length === 0) return;
+ var updates = {};
+ linkProps.forEach(function(p) { updates[p] = tokenObj.get(p); });
+ if (link.mode === 'chain') {
+ link.ids.forEach(function(tid) {
+ if (visited.has(tid)) return;
+ visited.add(tid);
+ var t = getObj('graphic', tid);
+ if (t) { t.set(updates); cascadeFrom(tid); }
+ });
+ } else if (idx === 0) {
+ link.ids.slice(1).forEach(function(tid) {
+ if (visited.has(tid)) return;
+ visited.add(tid);
+ var t = getObj('graphic', tid);
+ if (t) { t.set(updates); cascadeFrom(tid); }
+ });
+ }
+ });
+ };
+ cascadeFrom(sourceId);
+ };
+
+ /** Query links for a token. Returns array of { id, link } objects. */
+ const apiGetLinks = (tokenId) => findLinksForToken(tokenId);
+
+ /** Get/set global excludes. */
+ const apiGetGlobalExcludes = () => (state[SCRIPT_NAME].globalExcludes || []).slice();
+ const apiSetGlobalExcludes = (excludes) => { state[SCRIPT_NAME].globalExcludes = excludes; };
+
// =========================================================================
// Initialization
// =========================================================================
@@ -883,11 +1005,19 @@ var Mirror = Mirror || (() => {
return {
checkInstall,
registerEventHandlers,
- link: link,
- chainLink: chainLink,
- unlink: unlink,
+ link: apiLink,
+ chainLink: apiChainLink,
+ unlink: apiUnlink,
+ unchain: apiUnchain,
+ addToChain: apiAddToChain,
+ removeFromChain: apiRemoveFromChain,
+ align: apiAlign,
+ getLinks: apiGetLinks,
+ getGlobalExcludes: apiGetGlobalExcludes,
+ setGlobalExcludes: apiSetGlobalExcludes,
ALL_PROPS: ALL_PROPS,
- PROP_GROUPS: PROP_GROUPS
+ PROP_GROUPS: PROP_GROUPS,
+ getKnownProps: getKnownProps
};
})();
From c693abe0e17ad9998598f4bb4d5bee088ed7df90 Mon Sep 17 00:00:00 2001
From: Kenan Millet
Date: Mon, 15 Jun 2026 12:13:38 -0400
Subject: [PATCH 21/28] Mirror: add getParent, getChildren, getChainMembers
query helpers
---
Mirror/Mirror.js | 28 ++++++++++++++++++++++++++++
1 file changed, 28 insertions(+)
diff --git a/Mirror/Mirror.js b/Mirror/Mirror.js
index 4e04656056..9c351186f9 100644
--- a/Mirror/Mirror.js
+++ b/Mirror/Mirror.js
@@ -965,6 +965,31 @@ var Mirror = Mirror || (() => {
/** Query links for a token. Returns array of { id, link } objects. */
const apiGetLinks = (tokenId) => findLinksForToken(tokenId);
+ /** Get the parent (source) token ID for a one-way link, or null. */
+ const apiGetParent = (childId) => {
+ var links = findLinksForToken(childId);
+ var asChild = links.find(function(e) { return e.link.mode === 'link' && e.link.ids[0] !== childId; });
+ return asChild ? asChild.link.ids[0] : null;
+ };
+
+ /** Get child token IDs for one-way links where tokenId is the parent. */
+ const apiGetChildren = (parentId) => {
+ var children = [];
+ findLinksForToken(parentId).forEach(function(e) {
+ if (e.link.mode === 'link' && e.link.ids[0] === parentId) {
+ children = children.concat(e.link.ids.slice(1));
+ }
+ });
+ return children;
+ };
+
+ /** Get all token IDs in the same chain as tokenId, or empty array. */
+ const apiGetChainMembers = (tokenId) => {
+ var links = findLinksForToken(tokenId);
+ var chain = links.find(function(e) { return e.link.mode === 'chain'; });
+ return chain ? chain.link.ids.slice() : [];
+ };
+
/** Get/set global excludes. */
const apiGetGlobalExcludes = () => (state[SCRIPT_NAME].globalExcludes || []).slice();
const apiSetGlobalExcludes = (excludes) => { state[SCRIPT_NAME].globalExcludes = excludes; };
@@ -1013,6 +1038,9 @@ var Mirror = Mirror || (() => {
removeFromChain: apiRemoveFromChain,
align: apiAlign,
getLinks: apiGetLinks,
+ getParent: apiGetParent,
+ getChildren: apiGetChildren,
+ getChainMembers: apiGetChainMembers,
getGlobalExcludes: apiGetGlobalExcludes,
setGlobalExcludes: apiSetGlobalExcludes,
ALL_PROPS: ALL_PROPS,
From 5f77f31cff2ad9e55dcc279f44acd53e5e6aec1e Mon Sep 17 00:00:00 2001
From: Kenan Millet
Date: Mon, 15 Jun 2026 12:19:09 -0400
Subject: [PATCH 22/28] Mirror: add --help update, Help handout generation,
gen-dev-docs command
---
Mirror/Mirror.js | 65 +++++++++++++++++++++++++++++++++++++++++++-----
1 file changed, 59 insertions(+), 6 deletions(-)
diff --git a/Mirror/Mirror.js b/Mirror/Mirror.js
index 9c351186f9..44cbba5b75 100644
--- a/Mirror/Mirror.js
+++ b/Mirror/Mirror.js
@@ -787,15 +787,16 @@ var Mirror = Mirror || (() => {
};
const HELP_TEXT = '' + SCRIPT_NAME + ' v' + SCRIPT_VERSION + '
'
- + '' + CMD + ' link [--soft] [props] [ids...] -- Unidirectional (hard-lock by default)
'
- + '' + CMD + ' unlink [props] [ids...] -- Remove link
'
- + '' + CMD + ' chain [props] [ids...] -- Bidirectional ring
'
- + '' + CMD + ' unchain [props] [ids...] -- Remove chain
'
- + '' + CMD + ' align [--linked|--unlinked] [props] [ids...] -- Align tokens
'
+ + '' + CMD + ' link [--soft] [--align] [--exclude props] [props] [ids...] -- Unidirectional link (hard-lock default)
'
+ + '' + CMD + ' unlink [props] [ids...] -- Remove link or add excludes
'
+ + '' + CMD + ' chain [--align] [--exclude props] [props] [ids...] -- Bidirectional chain
'
+ + '' + CMD + ' unchain [props] [ids...] -- Remove chain or add excludes
'
+ + '' + CMD + ' align [--up|--down] [--linked|--unlinked] [--if-linked] [props] [ids...] -- Align tokens
'
+ '' + CMD + ' config [exclude|include|reset] [props] -- Global excludes
'
+ '' + CMD + ' status -- Show links for selected
'
+ '' + CMD + ' --help -- This help
'
- + '
Groups: all, spatial, position, size, bars, light, auras, flip
'
+ + '
Groups: all, spatial, position, size, bars, light, auras, flip'
+ + '
Flags: --soft, --align, --exclude, --up, --down, --linked, --unlinked, --if-linked'
+ '
Props: ' + ALL_PROPS.join(', ');
// =========================================================================
@@ -818,6 +819,7 @@ var Mirror = Mirror || (() => {
case 'align': doAlign(msg, args); break;
case 'config': doConfig(msg, args); break;
case 'status': doStatus(msg); break;
+ case 'gen-dev-docs': generateDevDocs(msg); break;
case '--help': reply(msg, HELP_TEXT); break;
default: reply(msg, HELP_TEXT); break;
}
@@ -1002,6 +1004,7 @@ var Mirror = Mirror || (() => {
ensureState();
log('-=> ' + SCRIPT_NAME + ' v' + SCRIPT_VERSION + ' Initialized <=-');
checkConfigDrift();
+ generateHelpHandout();
};
const checkConfigDrift = () => {
@@ -1022,6 +1025,56 @@ var Mirror = Mirror || (() => {
}
};
+ const generateHelpHandout = () => {
+ var name = 'Help: ' + SCRIPT_NAME;
+ var hh = findObjs({ type: 'handout', name: name })[0];
+ if (!hh) hh = createObj('handout', { name: name, inplayerjournals: 'all', archived: false });
+ var html = '' + SCRIPT_NAME + ' v' + SCRIPT_VERSION + '
';
+ html += 'Flat property syncing between tokens. No transforms — values are copied directly.
';
+ html += 'Commands
';
+ html += '!mirror link [--soft] [--align] [--exclude props] [props] [ids...] — Unidirectional link ';
+ html += '!mirror unlink [props] [ids...] — Remove link ';
+ html += '!mirror chain [--align] [--exclude props] [props] [ids...] — Bidirectional chain ';
+ html += '!mirror unchain [props] [ids...] — Remove chain ';
+ html += '!mirror align [--up|--down] [--linked|--unlinked] [--if-linked] [props] — Align tokens ';
+ html += '!mirror config [exclude|include|reset] [props] — Global excludes ';
+ html += '!mirror status — Show links ';
+ html += '!mirror --help — Command reference ';
+ html += '
';
+ html += 'Property Groups
';
+ html += 'all (dynamic), spatial (left,top,rotation,width,height), position (left,top), size (width,height), bars, light, auras, flip
';
+ html += 'Flags
';
+ html += '--soft: children can diverge (no hard lock)
';
+ html += '--align: align on link/chain creation
';
+ html += '--exclude: exclude props from all group
';
+ html += '--up: align to parent first
';
+ html += '--down: cascade from current value
';
+ html += '--if-linked: only align props that are actually linked
';
+ hh.set('notes', html);
+ };
+
+ const generateDevDocs = (msg) => {
+ var name = 'Help: ' + SCRIPT_NAME + '/Scripting API';
+ var hh = findObjs({ type: 'handout', name: name })[0];
+ if (!hh) hh = createObj('handout', { name: name, inplayerjournals: 'all', archived: false });
+ var html = '' + SCRIPT_NAME + ' — Scripting API
';
+ html += 'Access via Mirror.* after on("ready").
';
+ html += 'Linking
';
+ html += 'Mirror.link(ids, props, soft, excludes) // unidirectional\nMirror.chainLink(ids, props, excludes) // bidirectional chain
';
+ html += 'Unlinking
';
+ html += 'Mirror.unlink(ids, props) // remove link or add excludes\nMirror.unchain(ids, props) // remove chain or add excludes\nMirror.removeFromChain(tokenId) // remove one token from chain\nMirror.addToChain(existingId, newIds) // add tokens to chain
';
+ html += 'Alignment
';
+ html += 'Mirror.align(sourceId, { up, ifLinked, props })';
+ html += 'Queries
';
+ html += 'Mirror.getLinks(tokenId) // → [{ id, link }]\nMirror.getParent(childId) // → parentId or null\nMirror.getChildren(parentId) // → [childIds]\nMirror.getChainMembers(tokenId) // → [ids]';
+ html += 'Configuration
';
+ html += 'Mirror.getGlobalExcludes() // → [props]\nMirror.setGlobalExcludes(arr)\nMirror.getKnownProps() // → [all known prop names]
';
+ html += 'Constants
';
+ html += 'Mirror.ALL_PROPS // hardcoded prop list\nMirror.PROP_GROUPS // { spatial, position, size, bars, light, auras, flip }';
+ hh.set('notes', html);
+ reply(msg, 'Generated ' + name + ' — check your journal.');
+ };
+
const registerEventHandlers = () => {
on('chat:message', handleInput);
on('change:graphic', onGraphicChanged);
From ccf19594042e3e3592f141e94aa17b460786b574 Mon Sep 17 00:00:00 2001
From: Kenan Millet
Date: Mon, 15 Jun 2026 12:26:44 -0400
Subject: [PATCH 23/28] Mirror: prevent multiple hard parents and hard-linked
children from joining chains
---
Mirror/Mirror.js | 45 ++++++++++++++++++++++++++++++++++++++++-----
1 file changed, 40 insertions(+), 5 deletions(-)
diff --git a/Mirror/Mirror.js b/Mirror/Mirror.js
index 44cbba5b75..9949a8986f 100644
--- a/Mirror/Mirror.js
+++ b/Mirror/Mirror.js
@@ -191,11 +191,43 @@ var Mirror = Mirror || (() => {
const createLink = (mode, props, ids, soft, excludes) => {
var s = state[SCRIPT_NAME];
- var linkId = genId();
- s.links[linkId] = { props: props, ids: ids, mode: mode, soft: soft, excludes: excludes || [] };
+
+ // Guard: children in a hard link can't have multiple hard parents or join chains
+ if (mode === 'link' && !soft) {
+ // Check that child IDs (ids[1:]) don't already have a hard parent
+ for (var i = 1; i < ids.length; i++) {
+ var existing = findLinksForToken(ids[i]);
+ var hasHardParent = existing.some(function(e) {
+ return e.link.mode === 'link' && !e.link.soft && e.link.ids[0] !== ids[i];
+ });
+ if (hasHardParent) {
+ log(SCRIPT_NAME + ': Cannot hard-link ' + ids[i] + ' — already has a hard parent.');
+ return null;
+ }
+ if (s.chainedIds[ids[i]]) {
+ log(SCRIPT_NAME + ': Cannot hard-link ' + ids[i] + ' — token is in a chain.');
+ return null;
+ }
+ }
+ }
+
if (mode === 'chain') {
+ // Check that none of the IDs have a hard parent link as a child
+ for (var i = 0; i < ids.length; i++) {
+ var existing = findLinksForToken(ids[i]);
+ var hasHardParent = existing.some(function(e) {
+ return e.link.mode === 'link' && !e.link.soft && e.link.ids[0] !== ids[i];
+ });
+ if (hasHardParent) {
+ log(SCRIPT_NAME + ': Cannot chain ' + ids[i] + ' — has a hard parent link.');
+ return null;
+ }
+ }
ids.forEach(function(id) { s.chainedIds[id] = true; });
}
+
+ var linkId = genId();
+ s.links[linkId] = { props: props, ids: ids, mode: mode, soft: soft, excludes: excludes || [] };
return linkId;
};
@@ -420,7 +452,8 @@ var Mirror = Mirror || (() => {
reply(msg, 'Link', 'Added ' + linkProps.length + ' prop(s) to existing link (' + link.props.length + ' total).');
}
} else {
- createLink('link', linkProps, parsed.ids, parsed.soft, parsed.excludes);
+ var result = createLink('link', linkProps, parsed.ids, parsed.soft, parsed.excludes);
+ if (!result) { reply(msg, 'Error', 'Cannot create link — a child token already has a hard parent or is in a chain.'); return; }
if (parsed.align) {
var alignProps = linkProps === 'all' ? getKnownProps().filter(function(p) { return parsed.excludes.indexOf(p) === -1; }) : linkProps;
alignTokens(parsed.ids, alignProps);
@@ -514,7 +547,8 @@ var Mirror = Mirror || (() => {
} else {
// No existing chains: create new chain with 'all'
if (parsed.ids.length < 2) { reply(msg, 'Error', 'Chain requires at least 2 tokens.'); return; }
- createLink('chain', 'all', parsed.ids, true, parsed.excludes);
+ var result = createLink('chain', 'all', parsed.ids, true, parsed.excludes);
+ if (!result) { reply(msg, 'Error', 'Cannot create chain — a token has a hard parent link.'); return; }
if (parsed.align) alignTokens(parsed.ids, getKnownProps().filter(function(p) { return parsed.excludes.indexOf(p) === -1; }));
reply(msg, 'Chain', 'Chain-linked ' + parsed.ids.length + ' tokens (all props' + (parsed.align ? ', aligned' : '') + ').');
}
@@ -537,7 +571,8 @@ var Mirror = Mirror || (() => {
} else {
// No existing chains: create new
if (parsed.ids.length < 2) { reply(msg, 'Error', 'Chain requires at least 2 tokens.'); return; }
- createLink('chain', linkProps, parsed.ids, true, parsed.excludes);
+ var result = createLink('chain', linkProps, parsed.ids, true, parsed.excludes);
+ if (!result) { reply(msg, 'Error', 'Cannot create chain — a token has a hard parent link.'); return; }
if (parsed.align) {
var alignProps = linkProps === 'all' ? getKnownProps().filter(function(p) { return parsed.excludes.indexOf(p) === -1; }) : linkProps;
alignTokens(parsed.ids, alignProps);
From 1577a097fb1af63dcc3243657a4082b3779288a6 Mon Sep 17 00:00:00 2001
From: Kenan Millet
Date: Mon, 15 Jun 2026 12:35:53 -0400
Subject: [PATCH 24/28] Mirror: add 'anchor' property group for easy --exclude
of Anchor-managed props
---
Mirror/Mirror.js | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/Mirror/Mirror.js b/Mirror/Mirror.js
index 9949a8986f..83a292f4f6 100644
--- a/Mirror/Mirror.js
+++ b/Mirror/Mirror.js
@@ -55,7 +55,8 @@ var Mirror = Mirror || (() => {
'has_bright_light_vision', 'has_night_vision', 'night_vision_distance',
'emits_bright_light', 'bright_light_distance', 'emits_low_light', 'low_light_distance'],
auras: ['aura1_radius', 'aura1_color', 'aura1_square', 'aura2_radius', 'aura2_color', 'aura2_square'],
- flip: ['flipv', 'fliph']
+ flip: ['flipv', 'fliph'],
+ anchor: ['left', 'top', 'width', 'height', 'rotation', 'flipv', 'fliph', 'layer']
};
// =========================================================================
From f944248505b107a810c13e528515d920f3e208c1 Mon Sep 17 00:00:00 2001
From: Kenan Millet
Date: Mon, 15 Jun 2026 12:40:43 -0400
Subject: [PATCH 25/28] Mirror: add avatar to help and gen-dev-docs handouts
---
Mirror/Mirror.js | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/Mirror/Mirror.js b/Mirror/Mirror.js
index 83a292f4f6..2adb5cea4d 100644
--- a/Mirror/Mirror.js
+++ b/Mirror/Mirror.js
@@ -1064,7 +1064,7 @@ var Mirror = Mirror || (() => {
const generateHelpHandout = () => {
var name = 'Help: ' + SCRIPT_NAME;
var hh = findObjs({ type: 'handout', name: name })[0];
- if (!hh) hh = createObj('handout', { name: name, inplayerjournals: 'all', archived: false });
+ if (!hh) hh = createObj('handout', { name: name, inplayerjournals: 'all', archived: false, avatar: 'https://files.d20.io/images/127392204/tAiDP73rpSKQobEYm5QZUw/thumb.png?15878425385' });
var html = '' + SCRIPT_NAME + ' v' + SCRIPT_VERSION + '
';
html += 'Flat property syncing between tokens. No transforms — values are copied directly.
';
html += 'Commands
';
- hh.set('notes', html);
- };
-
- const generateDevDocs = (msg) => {
+ html += 'Using with Anchor
';
+ html += 'Use --exclude anchor on tokens that also use Anchor for spatial sync. This prevents overlap.
';
+ html += '!mirror chain --exclude anchor — sync everything except Anchor-managed props.
';
+ hh.set('notes', html); const generateDevDocs = (msg) => {
var name = 'Help: ' + SCRIPT_NAME + '/Scripting API';
var hh = findObjs({ type: 'handout', name: name })[0];
if (!hh) hh = createObj('handout', { name: name, inplayerjournals: 'all', archived: false, avatar: 'https://files.d20.io/images/127392204/tAiDP73rpSKQobEYm5QZUw/thumb.png?15878425385' });
@@ -1105,6 +1105,7 @@ var Mirror = Mirror || (() => {
html += 'Mirror.unlink(ids, props) // remove link or add excludes\nMirror.unchain(ids, props) // remove chain or add excludes\nMirror.removeFromChain(tokenId) // remove one token from chain\nMirror.addToChain(existingId, newIds) // add tokens to chain
';
html += 'Alignment
';
html += 'Mirror.align(sourceId, { up, ifLinked, props })';
+ html += 'Mirror.align(id, { ifLinked: true }) is the equivalent of Anchor.updateObj() — pushes linked props to all dependents.
';
html += 'Queries
';
html += 'Mirror.getLinks(tokenId) // → [{ id, link }]\nMirror.getParent(childId) // → parentId or null\nMirror.getChildren(parentId) // → [childIds]\nMirror.getChainMembers(tokenId) // → [ids]';
html += 'Configuration
';
diff --git a/Mirror/Mirror.js b/Mirror/Mirror.js
index 89fee55c2c..26c3e250e9 100644
--- a/Mirror/Mirror.js
+++ b/Mirror/Mirror.js
@@ -1090,10 +1090,10 @@ var Mirror = Mirror || (() => {
html += '--up: align to parent first
';
html += '--down: cascade from current value
';
html += '--if-linked: only align props that are actually linked';
- hh.set('notes', html);
- };
-
- const generateDevDocs = (msg) => {
+ html += 'Using with Anchor
';
+ html += 'Use --exclude anchor on tokens that also use Anchor for spatial sync. This prevents overlap.
';
+ html += '!mirror chain --exclude anchor — sync everything except Anchor-managed props.
';
+ hh.set('notes', html); const generateDevDocs = (msg) => {
var name = 'Help: ' + SCRIPT_NAME + '/Scripting API';
var hh = findObjs({ type: 'handout', name: name })[0];
if (!hh) hh = createObj('handout', { name: name, inplayerjournals: 'all', archived: false, avatar: 'https://files.d20.io/images/127392204/tAiDP73rpSKQobEYm5QZUw/thumb.png?15878425385' });
@@ -1105,6 +1105,7 @@ var Mirror = Mirror || (() => {
html += 'Mirror.unlink(ids, props) // remove link or add excludes\nMirror.unchain(ids, props) // remove chain or add excludes\nMirror.removeFromChain(tokenId) // remove one token from chain\nMirror.addToChain(existingId, newIds) // add tokens to chain
';
html += 'Alignment
';
html += 'Mirror.align(sourceId, { up, ifLinked, props })';
+ html += 'Mirror.align(id, { ifLinked: true }) is the equivalent of Anchor.updateObj() — pushes linked props to all dependents.
';
html += 'Queries
';
html += 'Mirror.getLinks(tokenId) // → [{ id, link }]\nMirror.getParent(childId) // → parentId or null\nMirror.getChildren(parentId) // → [childIds]\nMirror.getChainMembers(tokenId) // → [ids]';
html += 'Configuration
';
diff --git a/Mirror/README.md b/Mirror/README.md
index db681d338c..6bda9885e5 100644
--- a/Mirror/README.md
+++ b/Mirror/README.md
@@ -79,6 +79,23 @@ Mirror.ALL_PROPS
Mirror.PROP_GROUPS
```
+## Using with Anchor
+
+Mirror and Anchor complement each other — Anchor handles spatial transforms (position, rotation, scale with offsets), Mirror handles flat property copying (bars, status, light, etc.).
+
+To avoid conflicts on tokens that use both:
+
+```
+!mirror chain --exclude anchor
+```
+
+This syncs everything *except* what Anchor manages (left, top, rotation, width, height, flipv, fliph, layer).
+
+**API equivalent of `Anchor.updateObj`:**
+```javascript
+Mirror.align(tokenId, { ifLinked: true }) // push linked props to dependents
+```
+
## License
MIT