diff --git a/ScriptKit/1.3.0/ScriptKit.js b/ScriptKit/1.3.0/ScriptKit.js new file mode 100644 index 000000000..718fa1923 --- /dev/null +++ b/ScriptKit/1.3.0/ScriptKit.js @@ -0,0 +1,2962 @@ +// ============================================================================= +// ScriptKit v1.3.0 +// Last Updated: 2026-08-16 +// Author: Kenan Millet +// +// Description: +// Generic framework for Roll20 API scripts. Provides example/guide system, +// handout generation, and initialization coordination. +// +// Not a standalone script โ€” provides a framework for other scripts to use. +// +// Dependencies: none +// ============================================================================= +/* global on, sendChat, getObj, findObjs, createObj, playerIsGM, log, state */ + +var ScriptKit = ScriptKit || (() => { + 'use strict'; + + const SCRIPT_NAME = 'ScriptKit'; + const SCRIPT_VERSION = '1.3.0'; + const HANDOUT_STEP = Object.freeze({ auto: true }); + + // In-memory MOTD tracking (resets on sandbox restart) + const motdSeen = {}; // { scriptName: Set of shown indices } + + const pickMotd = (scriptName, motdArr) => { + if (!motdArr || motdArr.length === 0) return null; + if (!motdSeen[scriptName]) motdSeen[scriptName] = new Set(); + var seen = motdSeen[scriptName]; + if (seen.size >= motdArr.length) seen.clear(); + var unseen = motdArr.map((_, i) => i).filter(i => !seen.has(i)); + var idx = unseen[Math.floor(Math.random() * unseen.length)]; + seen.add(idx); + return motdArr[idx]; + }; + + const renderMotdCard = (scriptName, tip, reg, btnCmd) => { + var frameStyle = Object.assign( + { background: '#1a1a2e', color: '#eee', padding: '8px 12px', borderRadius: '4px', borderLeft: '3px solid #4fc3f7', fontSize: '12px', marginTop: '4px' }, + (reg && reg.motdStyle) || {} + ); + var version = reg ? reg.version : ''; + var header = reg && reg.motdHeader + ? (typeof reg.motdHeader === 'function' ? reg.motdHeader(version) : reg.motdHeader) + : '๐Ÿ’ก **' + scriptName + ' v' + version + '**'; + var anotherCmd = btnCmd || (reg ? reg.command + ' motd' : '!scriptkit motd'); + // Derive button style: explicit motdButtonStyle > derive from frame > default + var btnStyle = null; + if (reg && reg.motdButtonStyle) { + btnStyle = reg.motdButtonStyle; + } else { + var borderMatch = frameStyle.borderLeft?.match(/#[0-9a-fA-F]{3,8}|rgb[a]?\([^)]+\)/); + btnStyle = {}; + if (borderMatch) btnStyle.border = '1px solid ' + borderMatch[0]; + } + var showAnotherBtn = html.br() + html.br() +'
' + html.button('ยป Next Tip ยป', anotherCmd, btnStyle) + '
'; + return html.div( + '
' + html.render(header) + '
' + html.br() + html.render(tip) + showAnotherBtn, + frameStyle + ); + }; + + // ========================================================================= + // Registry + // ========================================================================= + + // { 'ScriptName': { command, tag, aliases, exampleHandler } } + const registrations = {}; + + // { 'ScriptName/exampleName': { name, description, source, guide, ...custom } } + const examples = {}; + + // { guideId: { steps, currentStep, selections, params, msg, handoutName, source, onComplete } } + const activeGuides = {}; + + // Guide annotation system โ€” temporary visual aids that auto-clear between steps + // Stored in state for crash recovery + var _annotations = []; + + const clearAnnotations = () => { + _annotations.forEach(function(id) { + var obj = getObj('pathv2', id); + if (obj) obj.remove(); + }); + _annotations = []; + if (state.ScriptKit) state.ScriptKit._annotations = []; + }; + + const guideAnnotate = (pageId, shape, x, y, opts) => { + opts = opts || {}; + + // Compute shape-specific geometry + var points, cx = x, cy = y, pathShape; + if (shape === 'circle') { + var r = opts.radius || 40; + points = [[0, 0], [r * 2, r * 2]]; + pathShape = 'eli'; + } else if (shape === 'arrow' || shape === 'line') { + var fromX = opts.fromX != null ? opts.fromX : x - 50; + var fromY = opts.fromY != null ? opts.fromY : y - 50; + var dx = x - fromX; + var dy = y - fromY; + var len = Math.sqrt(dx * dx + dy * dy) || 1; + var ux = dx / len; + var uy = dy / len; + var pts; + if (shape === 'arrow') { + var chevDepth = opts.chevronDepth || len * 0.2; + var chevWidth = opts.chevronWidth || chevDepth * 0.6; + // Chevron points: two points offset from the tip along and perpendicular to the shaft + var backX = x - ux * chevDepth; + var backY = y - uy * chevDepth; + var h1x = backX - uy * chevWidth; + var h1y = backY + ux * chevWidth; + var h2x = backX + uy * chevWidth; + var h2y = backY - ux * chevWidth; + pts = [[fromX, fromY], [x, y], [h1x, h1y], [x, y], [h2x, h2y]]; + } else { + pts = [[fromX, fromY], [x, y]]; + } + var minX = Math.min.apply(null, pts.map(function(p) { return p[0]; })); + var minY = Math.min.apply(null, pts.map(function(p) { return p[1]; })); + var maxX = Math.max.apply(null, pts.map(function(p) { return p[0]; })); + var maxY = Math.max.apply(null, pts.map(function(p) { return p[1]; })); + cx = (minX + maxX) / 2; + cy = (minY + maxY) / 2; + points = pts.map(function(p) { return [p[0] - cx, p[1] - cy]; }); + pathShape = 'pol'; + } else if (shape === 'rect') { + var rw = opts.width || 80; + var rh = opts.height || 80; + points = [[0, 0], [rw, rh]]; + pathShape = 'rec'; + } else { + return null; + } + + // Create with unified properties + var obj = createObj('pathv2', { + _pageid: pageId, + layer: 'foreground', + shape: pathShape, + x: cx, y: cy, + points: JSON.stringify(points), + stroke: opts.color || '#ff0000', + stroke_width: opts.strokeWidth || 3, + fill: opts.fill || 'transparent', + }); + + if (obj) { + _annotations.push(obj.get('id')); + if (state.ScriptKit) state.ScriptKit._annotations = _annotations.slice(); + } + return obj; + }; + + var _pingColorState = {}; // playerId โ†’ { originalColor, pending } + + const guidePing = (pageId, x, y, opts) => { + opts = opts || {}; + var player = opts.player || null; + var playerId = player ? player.get('id') : (opts.playerId || null); + var moveAll = opts.moveAll !== false; + var visibleTo = opts.visibleTo || playerId || undefined; + var color = opts.color || null; + + if (color && player) { + var pid = player.get('id'); + if (!_pingColorState[pid]) { + _pingColorState[pid] = { originalColor: player.get('color'), pending: 0 }; + } + _pingColorState[pid].pending++; + player.set('color', color); + setTimeout(function() { + sendPing(x, y, pageId, playerId, moveAll, visibleTo); + setTimeout(function() { + _pingColorState[pid].pending--; + if (_pingColorState[pid].pending <= 0) { + player.set('color', _pingColorState[pid].originalColor); + delete _pingColorState[pid]; + } + }, 500); + }, 150); + } else { + sendPing(x, y, pageId, playerId, moveAll, visibleTo); + } + }; + + // ========================================================================= + // HTML Helpers + // ========================================================================= + + const html = { + escape: (str) => (str || '').replace(/&/g, '&').replace(//g, '>').replace(/\n/g, '
'), + raw: (str) => ({ __raw: true, toString: () => str, value: str }), + render: (text) => { + if (!text) return ''; + if (text.__raw) return text.value; + return html.format(String(text)); + }, + style: (obj) => { + if (!obj) return ''; + if (typeof obj === 'string') return obj; + return Object.entries(obj).map(([k, v]) => k.replace(/[A-Z]/g, c => '-' + c.toLowerCase()) + ':' + v).join(';'); + }, + _tag: (tag, text, style) => '<' + tag + (style ? ' style="' + html.style(style) + '"' : '') + '>' + text + '', + bold: (text, style) => html._tag('b', text, style), + italic: (text, style) => html._tag('i', text, style), + underline: (text, style) => html._tag('u', text, style), + small: (text, style) => html._tag('small', text, style), + sup: (text, style) => html._tag('sup', text, style), + code: (text, style) => html._tag('code', html.escape(text), style), + pre: (text, style) => html._tag('pre', text, style), + paragraph: (text, style) => html._tag('p', text, style), + div: (text, style) => html._tag('div', text, style), + span: (text, style) => html._tag('span', text, style), + line: () => '
', + br: () => '
', + indent: (n) => ' '.repeat(n || 2), + link: (text, url, style) => '' + text + '', + handoutLink: (text, id, style, anchor) => '' + text + '', + version: (ver, style) => html.sup('[v' + ver + ']', style), + newBadge: (style) => html.sup('[new]', style ? style : { color: '#c33', fontWeight: 'bold' }), + deprecatedBadge: (ver, style) => html.sup('[deprecated' + (ver ? ' v' + ver : '') + ']', style ? style : { color: '#323' }), + list: (items, style) => html._tag('ul', items.map(i => '
  • ' + i + '
  • ').join(''), style), + orderedList: (items, style) => html._tag('ol', items.map(i => '
  • ' + i + '
  • ').join(''), style), + section: (title, content) => html.bold(title) + html.br() + content, + format: (str) => { + if (!str) return ''; + // First pass: handle backslash escapes for markdown-special chars only + var escaped = ''; + for (var i = 0; i < str.length; i++) { + if (str[i] === '\\' && i + 1 < str.length) { + var next = str[i + 1]; + if (next === '`' || next === '*' || next === '\\') { + escaped += '&#' + next.charCodeAt(0) + ';'; + i++; + } else { + escaped += str[i]; + } + } else { + escaped += str[i]; + } + } + var out = html.escape(escaped); + // Extract code/pre spans into placeholders (protects content from markdown) + var codeSlots = []; + out = out.replace(/```([^`]+)```/g, function(_, content) { + codeSlots.push('
    ' + content.replace(/\*/g, '*').replace(/\[/g, '[') + '
    '); + return '\uFFFECODE' + (codeSlots.length - 1) + '\uFFFE'; + }); + out = out.replace(/`([^`]+)`/g, function(_, content) { + codeSlots.push('' + content.replace(/\*/g, '*').replace(/\[/g, '[') + ''); + return '\uFFFECODE' + (codeSlots.length - 1) + '\uFFFE'; + }); + // Apply markdown (placeholders don't contain * so won't interfere) + out = out.replace(/\*\*([^*]+)\*\*/g, '$1'); + out = out.replace(/\*([^*]+)\*/g, '$1'); + // Restore code/pre spans + out = out.replace(/\uFFFECODE(\d+)\uFFFE/g, function(_, idx) { + return codeSlots[parseInt(idx, 10)]; + }); + return out; + }, + button: (label, command, style) => { + var defaultStyle = { background: '#333', color: '#fff', padding: '1px 6px', borderRadius: '3px', textDecoration: 'none' }; + var merged = style ? Object.assign({}, defaultStyle, style) : defaultStyle; + return '' + label + ''; + }, + table: (headers, rows, style) => { + var tableStyle = style ? html.style(style) : 'border-collapse:collapse'; + var h = '
    ' + headers.map(col => '').join('') + ''; + h += rows.map(row => '' + row.map(cell => '').join('') + '').join(''); + h += '
    ' + col + '
    ' + cell + '
    '; + return h; + }, + /** + * Clickable text button that pings an object's location. + * @param {string|object} target - Object ID string, or { pageid, x, y } + * @param {object} [opts] - color, moveAll, visibleTo, label, style + */ + pingObjBtn: (target, opts) => { + opts = opts || {}; + var cmd = pingCommand(target, opts); + var label = opts.label || (typeof target === 'string' ? target : 'Ping'); + var defaultStyle = { background: '#333', color: '#fff', padding: '1px 6px', borderRadius: '3px', textDecoration: 'none' }; + var merged = opts.style ? Object.assign({}, defaultStyle, opts.style) : defaultStyle; + return '' + label + ''; + }, + /** + * Clickable token image that pings an object's location. + * @param {string|object} target - Object ID string, or { pageid, x, y } + * @param {object} [opts] - color, moveAll, visibleTo, imgsrc, width, height, label (shown below image or as fallback text) + */ + pingObjImg: (target, opts) => { + opts = opts || {}; + var cmd = pingCommand(target, opts); + var width = opts.width || 40; + var height = opts.height || 40; + var imgsrc = opts.imgsrc || null; + if (imgsrc) { + // Ensure thumb version for Roll20 chat rendering + imgsrc = imgsrc.replace(/\/(?:max|med|original)\.(png|jpg|jpeg|webp)/i, '/thumb.$1'); + var imgStyle = 'width:' + width + 'px;height:' + height + 'px;border-radius:4px;'; + var label = opts.label ? '
    ' + opts.label + '' : ''; + return '' + + '' + label + ''; + } + // Fallback: text button + var fallbackLabel = opts.label || (typeof target === 'string' ? target.slice(0, 8) + '...' : 'Ping'); + return html.pingObjBtn(target, Object.assign({}, opts, { label: fallbackLabel })); + }, + }; + + // ========================================================================= + // Helpers + // ========================================================================= + + const getPlayerName = (playerid) => { + if (!playerid || playerid === 'API') return 'gm'; + const player = getObj('player', playerid); + return player ? player.get('_displayname') : 'gm'; + }; + + const reply = (msg, scriptName, tag, text) => { + const recipient = getPlayerName(msg.playerid); + sendChat(scriptName + (tag ? ' [' + tag + ']' : ''), '/w "' + recipient + '" ' + text, null, { noarchive: true }); + }; + + const replyError = (msg, scriptName, text) => reply(msg, scriptName, 'Error', html.render(text)); + + const setHandoutNotes = (handout, notes) => { + // Retry pattern for Roll20's async handout issues + handout.set('notes', notes); + setTimeout(() => handout.set('notes', notes), 200); + }; + + /** + * Resolve a registration field value. If the value is a function, call it + * (optionally passing ctx) and return the result. Otherwise return as-is. + * This enables lazy evaluation of any registration field. + */ + const resolve = (value, ctx) => typeof value === 'function' ? value(ctx) : value; + + // ========================================================================= + // Registration API + // ========================================================================= + + // Pending example queue for scripts not yet registered + const pendingExamples = {}; // { 'TargetScript': [ { registeredBy, struct }, ... ] } + + /** + * Register a script with the Tutorial system. + * + * @param {string} scriptName Display name of the script. + * @param {object} opts Configuration: + * @param {string} opts.command Chat command prefix (e.g. '!gaslight') + * @param {string} opts.tag Default handout tag (e.g. 'Scene', 'Sequence', 'Script') + * @param {object} [opts.aliases] Command name overrides (defaults provided) + * @param {function} [opts.exampleHandler] (example, msg) โ†’ { notes, gmnotes, avatar, tag, ... } + * @param {function} [opts.onComplete] (ctx, handout) โ†’ void โ€” called when guide completes + */ + const register = (scriptName, opts) => { + if (!scriptName || !opts || !opts.command || !opts.version) { + log(SCRIPT_NAME + ': register requires scriptName, command, and version.'); + return false; + } + if (registrations[scriptName]) return true; // already registered + registrations[scriptName] = { + _scriptName: scriptName, + command: opts.command.startsWith('!') ? opts.command : '!' + opts.command, + tag: opts.tag || null, + aliases: (() => { + const defaults = { + help: ['help', '--help'], + man: 'man', + whatsnew: 'whatsnew', + changes: 'changes', + motd: 'motd', + genHelp: 'gen-help', + genDev: 'gen-dev-docs', + examples: 'examples', + generate: 'example!', + guide: 'guide', + guideContinue: 'guide-continue', + guideBack: 'guide-back', + guideCancel: 'guide-cancel', + migrate: 'migrate', + }; + var userAliases = opts.aliases || {}; + Object.keys(defaults).forEach(k => { + var val = userAliases[k]; + if (val !== undefined) defaults[k] = val; + }); + // Auto-null aliases that conflict with registered commands + if (opts.help && opts.help.commands) { + var flatCmds = []; + var flatten = (items) => { items.forEach(c => { if (c.group) flatten(c.commands || []); else flatCmds.push(c); }); }; + flatten(opts.help.commands); + var cmdFirstWords = new Set(flatCmds.filter(c => !c.deleted && c.syntax).map(c => c.syntax.split(' ')[0].toLowerCase())); + Object.keys(defaults).forEach(k => { + if (!defaults[k] || userAliases[k] !== undefined) return; // skip nulled or explicitly set + var aliases = Array.isArray(defaults[k]) ? defaults[k] : [defaults[k]]; + if (aliases.some(a => cmdFirstWords.has(a.toLowerCase()))) { + defaults[k] = null; + } + }); + } + return defaults; + })(), + exampleHandler: opts.exampleHandler || null, + onComplete: opts.onComplete || null, + version: opts.version || null, + stateRef: opts.state || null, + migrations: opts.migrations || null, + onMigrationFailure: opts.onMigrationFailure || null, + help: opts.help || null, + newSince: opts.newSince || null, + handoutMode: opts.handout || 'auto', // 'auto' | 'update' | 'manual' + devHandoutMode: opts.devHandout || 'update', // 'auto' | 'update' | 'manual' + motd: opts.motd || null, + motdHeader: opts.motdHeader !== undefined ? opts.motdHeader : null, + motdStyle: opts.motdStyle || null, + motdButtonStyle: opts.motdButtonStyle || null, + _handouts: { usr: null, dev: null }, + }; + + // Drain pending queue for this script + if (pendingExamples[scriptName]) { + pendingExamples[scriptName].forEach(pending => { + registerExample(scriptName, pending.registeredBy, pending.struct); + }); + delete pendingExamples[scriptName]; + } + + // Run migrations if version and migrations provided + if (opts.version && opts.migrations && opts.state) { + runMigrations(scriptName, opts); + } + + // Track version changes (for [new] badges and handout regeneration) + if (opts.version) { + ensureState(); + var currentStored = state[SCRIPT_NAME].versions[scriptName]; + if (currentStored && compareSemver(currentStored, opts.version) !== 0) { + state[SCRIPT_NAME].previousVersions[scriptName] = currentStored; + } + state[SCRIPT_NAME].versions[scriptName] = opts.version; + + // Track version dates + if (!state[SCRIPT_NAME].versionDates[scriptName]) state[SCRIPT_NAME].versionDates[scriptName] = {}; + var vDates = state[SCRIPT_NAME].versionDates[scriptName]; + // Store dates from changelog entries (canonical source for historical versions) + if (opts.help && opts.help.changelog) { + opts.help.changelog.forEach(function(entry) { + if (entry.date && !vDates[entry.version]) { + var parsed = new Date(entry.date).getTime(); + if (!isNaN(parsed)) vDates[entry.version] = parsed; + } + }); + } + // Auto-stamp current version if no date stored yet + if (!vDates[opts.version]) { + vDates[opts.version] = opts.versionDate ? new Date(opts.versionDate).getTime() : Date.now(); + } + } + + var usrHandout = findObjs({ type: 'handout', name: 'Help: ' + scriptName })[0]; + if (usrHandout) registrations[scriptName]._handouts.usr = usrHandout; + // Auto-generate user help handout based on handoutMode ('auto' | 'update' | 'manual') + if (opts.help && opts.version && registrations[scriptName].handoutMode !== 'manual') { + ensureState(); + var storedVer = state[SCRIPT_NAME].versions[scriptName] || '0.0.0'; + var versionChanged = compareSemver(storedVer, opts.version) !== 0; + var shouldGenerate = registrations[scriptName].handoutMode === 'auto' + ? (versionChanged || !usrHandout) + : (versionChanged && usrHandout); // 'update' mode: only if exists AND version changed + if (shouldGenerate) { + setTimeout(() => generateHelpHandout(null, scriptName, registrations[scriptName], 'usr'), 500); + } + } + + var devHandout = findObjs({ type: 'handout', name: 'Help: ' + scriptName + '/Dev' })[0]; + if (devHandout) registrations[scriptName]._handouts.dev = devHandout; + // Auto-generate dev handout based on devHandoutMode ('auto' | 'update' | 'manual') + if (opts.help && opts.version && registrations[scriptName].devHandoutMode !== 'manual') { + ensureState(); + var storedVerDev = state[SCRIPT_NAME].versions[scriptName] || '0.0.0'; + var versionChangedDev = compareSemver(storedVerDev, opts.version) !== 0; + var shouldGenerateDev = registrations[scriptName].devHandoutMode === 'auto' + ? (versionChangedDev || !devHandout) + : (versionChangedDev && devHandout); + if (shouldGenerateDev) { + setTimeout(() => generateHelpHandout(null, scriptName, registrations[scriptName], 'dev'), 600); + } + } + + if (opts.version) { + var _d = new Date(vDates[opts.version]); + var _ds = _d.getFullYear() + '/' + String(_d.getMonth() + 1).padStart(2, '0') + '/' + String(_d.getDate()).padStart(2, '0'); + log(`ศ˜๊€ โš™โš™ ${scriptName} version ${opts.version} (${_ds}) ready.`); + } + + // Send ready signal + sendChat('', registrations[scriptName].command + '-ready', null, { noarchive: true }); + + // Startup debounce: 10s after last registration, show What's New card + random motd + if (register._startupTimer) clearTimeout(register._startupTimer); + register._startupTimer = setTimeout(() => { + delete register._startupTimer; + ensureState(); + var lastSeen = state[SCRIPT_NAME].lastSeenVersions; + + // What's New card โ€” show if any plugin upgraded since last seen + var upgradedPlugins = []; + Object.keys(registrations).forEach(name => { + var r = registrations[name]; + if (name === SCRIPT_NAME) return; // skip ScriptKit itself + if (!r.help || !r.help.changelog || !r.version) return; + var seen = lastSeen[name]; + if (!seen) { lastSeen[name] = r.version; return; } // first install โ€” store, no card + if (compareSemver(seen, r.version) >= 0) return; // already seen this version + // Collect changelog entries between lastSeen and current + var entries = r.help.changelog.filter(e => compareSemver(e.version, seen) > 0 && compareSemver(e.version, r.version) <= 0); + if (entries.length > 0) upgradedPlugins.push({ name, reg: r, entries }); + }); + + if (upgradedPlugins.length > 0) { + var out = html.bold("What's New") + html.paragraph(''); + upgradedPlugins.forEach(p => { + out += html.bold(html.escape(p.name) + ' v' + p.reg.version) + html.br(); + var changes = []; + p.entries.forEach(e => { if (Array.isArray(e.changes)) changes.push(...e.changes); }); + if (changes.length > 0) out += html.list(changes.slice(0, 5).map(c => html.escape(c))); + if (changes.length > 5) out += html.small('...and ' + (changes.length - 5) + ' more') + html.br(); + if (p.reg.aliases && p.reg.aliases.whatsnew) out += html.button('See Details', p.reg.command + ' ' + p.reg.aliases.whatsnew) + html.br(); + out += html.br(); + }); + out += html.br() + '
    ' + html.button('โœ“ Dismiss', '!scriptkit dismiss-whatsnew') + '
    '; + sendChat(SCRIPT_NAME, '/w gm ' + out, null, { noarchive: true }); + } + + // MOTD โ€” one random tip from global pool + var pluginsWithMotd = Object.keys(registrations).filter(n => registrations[n].motd && registrations[n].motd.length > 0); + if (pluginsWithMotd.length > 0) { + var randomPlugin = pluginsWithMotd[Math.floor(Math.random() * pluginsWithMotd.length)]; + var rReg = registrations[randomPlugin]; + var tip = pickMotd(randomPlugin, rReg.motd); + if (tip) { + var card = renderMotdCard(randomPlugin, tip, rReg, '!scriptkit motd'); + sendChat(SCRIPT_NAME, '/w gm ' + card, null, { noarchive: true }); + } + } + }, 10000); + + return true; + }; + + // ========================================================================= + // Migrations + // ========================================================================= + + const ensureState = () => { + if (!state[SCRIPT_NAME]) state[SCRIPT_NAME] = {}; + if (!state[SCRIPT_NAME].versions) state[SCRIPT_NAME].versions = {}; + if (!state[SCRIPT_NAME].previousVersions) state[SCRIPT_NAME].previousVersions = {}; + if (!state[SCRIPT_NAME].migrations) state[SCRIPT_NAME].migrations = {}; + if (!state[SCRIPT_NAME].versionDates) state[SCRIPT_NAME].versionDates = {}; + if (!state[SCRIPT_NAME].lastSeenVersions) state[SCRIPT_NAME].lastSeenVersions = {}; + }; + + /** + * Compare semver strings. Returns -1, 0, or 1. + */ + const compareSemver = (a, b) => { + const pa = a.split(/[.\-]/); + const pb = b.split(/[.\-]/); + const len = Math.max(pa.length, pb.length); + for (let i = 0; i < len; i++) { + const ra = pa[i] || ''; + const rb = pb[i] || ''; + if (ra === rb) continue; + if (ra === '') return -1; + if (rb === '') return 1; + const na = Number(ra); + const nb = Number(rb); + if (!isNaN(na) && !isNaN(nb)) { + if (na < nb) return -1; + if (na > nb) return 1; + } else { + if (ra < rb) return -1; + if (ra > rb) return 1; + } + } + return 0; + }; + + /** + * Determine if an item's version qualifies as "new". + * If newSince is set: item.version >= newSince + * Default: item's major.minor > previous stored major.minor + */ + const isNewVersion = (itemVersion, reg) => { + if (!itemVersion || !reg.version) return false; + ensureState(); + var prev = state[SCRIPT_NAME].previousVersions[reg._scriptName]; + if (!prev) return false; // first install โ€” nothing is "new" + if (reg.newSince) { + // newSince is set: item is new if >= newSince OR if > previousVersion + if (compareSemver(itemVersion, reg.newSince) >= 0) return true; + return compareSemver(itemVersion, prev) > 0; + } + // Default: compare major.minor against previous stored version (> not >=) + var prevMinor = prev.split(/[.\-]/).slice(0, 2).join('.'); + var itemMinor = itemVersion.split(/[.\-]/).slice(0, 2).join('.'); + return compareSemver(itemMinor, prevMinor) > 0; + }; + + /** + * Get sorted migration version keys between two versions. + */ + const getMigrationsBetween = (migrations, fromVersion, toVersion, direction) => { + const versions = Object.keys(migrations).sort(compareSemver); + if (direction === 'up') { + return versions.filter(v => compareSemver(v, fromVersion) > 0 && compareSemver(v, toVersion) <= 0); + } else { + // Rollback: versions from stored down to (but not including) target, in reverse + return versions.filter(v => compareSemver(v, toVersion) > 0 && compareSemver(v, fromVersion) <= 0).reverse(); + } + }; + + /** + * Run forward migrations automatically on register. + */ + const runMigrations = (scriptName, opts) => { + ensureState(); + const storedVersion = state[SCRIPT_NAME].versions[scriptName] || '0.0.0'; + const currentVersion = opts.version; + + if (compareSemver(storedVersion, currentVersion) === 0) return; // up to date + + if (compareSemver(storedVersion, currentVersion) > 0) { + // Downgrade detected โ€” warn, don't auto-rollback + log(SCRIPT_NAME + ': ' + scriptName + ' downgraded from ' + storedVersion + ' to ' + currentVersion + '. Run ' + opts.command + ' ' + (registrations[scriptName].aliases.migrate || 'migrate') + ' to revert state.'); + // Whisper on next chat opportunity (can't whisper during ready) + setTimeout(() => { + sendChat(scriptName, '/w gm โš ๏ธ ' + html.bold(scriptName) + ' downgraded from ' + storedVersion + ' to ' + currentVersion + '. State may be incompatible. Use ' + html.code(opts.command + ' ' + (registrations[scriptName].aliases.migrate || 'migrate')) + ' to revert state.', null, { noarchive: true }); + }, 1000); + return; + } + + // Upgrade โ€” run migrations on a working copy, commit only if all succeed + const toRun = getMigrationsBetween(opts.migrations, storedVersion, currentVersion, 'up'); + var working = JSON.parse(JSON.stringify(opts.state)); + var downStrings = {}; + for (let i = 0; i < toRun.length; i++) { + const v = toRun[i]; + const migration = opts.migrations[v]; + const upFn = typeof migration === 'function' ? migration : migration.up; + if (typeof upFn !== 'function') continue; + try { + upFn(working); + if (typeof migration === 'object' && typeof migration.down === 'string') { + downStrings[v] = migration.down; + } else if (typeof migration === 'object' && migration.down && typeof migration.down !== 'string') { + log(SCRIPT_NAME + ': ' + scriptName + ' migration ' + v + ' \'down\' must be a string (for persistence). It will not be stored for future rollback.'); + } + } catch (e) { + log(SCRIPT_NAME + ': ' + scriptName + ' migration to ' + v + ' FAILED: ' + e.message); + sendChat(scriptName, '/w gm โŒ ' + html.bold(scriptName) + ' migration to ' + v + ' failed: ' + e.message + '. State unchanged (version ' + storedVersion + ').', null, { noarchive: true }); + const reg = registrations[scriptName]; + if (reg && typeof reg.onMigrationFailure === 'function') { + reg.onMigrationFailure({ version: v, direction: 'up', error: e, currentStoredVersion: storedVersion }); + } + return; + } + } + + // All migrations succeeded โ€” commit working copy to real state + Object.keys(opts.state).forEach(k => delete opts.state[k]); + Object.assign(opts.state, working); + // Persist down strings + if (Object.keys(downStrings).length > 0) { + if (!state[SCRIPT_NAME].migrations[scriptName]) state[SCRIPT_NAME].migrations[scriptName] = {}; + Object.assign(state[SCRIPT_NAME].migrations[scriptName], downStrings); + } + + if (toRun.length > 0) { + log(SCRIPT_NAME + ': ' + scriptName + ' migrated from ' + storedVersion + ' to ' + currentVersion + ' (' + toRun.length + ' migration(s)).'); + } + }; + + /** + * Handle migrate command โ€” sync state version to current script version. + */ + const handleMigrateCommand = (msg, scriptName, reg, args) => { + ensureState(); + const storedVersion = state[SCRIPT_NAME].versions[scriptName] || '0.0.0'; + const currentVersion = reg.version; + + if (compareSemver(storedVersion, currentVersion) === 0) { + reply(msg, scriptName, 'Migrate', 'Already at target version ' + html.bold(currentVersion) + '.'); + return; + } + + if (compareSemver(storedVersion, currentVersion) < 0) { + // Forward โ€” run up migrations on working copy + if (!reg.migrations || !reg.stateRef) { + replyError(msg, scriptName, 'No migrations registered for ' + scriptName + '.'); + return; + } + const toRun = getMigrationsBetween(reg.migrations, storedVersion, currentVersion, 'up'); + var working = JSON.parse(JSON.stringify(reg.stateRef)); + var downStrings = {}; + for (let i = 0; i < toRun.length; i++) { + const v = toRun[i]; + const migration = reg.migrations[v]; + const upFn = typeof migration === 'function' ? migration : migration.up; + if (typeof upFn !== 'function') continue; + try { + upFn(working); + if (typeof migration === 'object' && typeof migration.down === 'string') { + downStrings[v] = migration.down; + } else if (typeof migration === 'object' && migration.down && typeof migration.down !== 'string') { + log(SCRIPT_NAME + ': ' + scriptName + ' migration ' + v + ' \'down\' must be a string (for persistence). It will not be stored for future rollback.'); + } + } catch (e) { + replyError(msg, scriptName, 'Migration to ' + v + ' failed: ' + e.message + '. State unchanged (version ' + storedVersion + ').'); + if (typeof reg.onMigrationFailure === 'function') { + reg.onMigrationFailure({ version: v, direction: 'up', error: e, currentStoredVersion: storedVersion }); + } + return; + } + } + // Commit + Object.keys(reg.stateRef).forEach(k => delete reg.stateRef[k]); + Object.assign(reg.stateRef, working); + if (Object.keys(downStrings).length > 0) { + if (!state[SCRIPT_NAME].migrations[scriptName]) state[SCRIPT_NAME].migrations[scriptName] = {}; + Object.assign(state[SCRIPT_NAME].migrations[scriptName], downStrings); + } + state[SCRIPT_NAME].versions[scriptName] = currentVersion; + reply(msg, scriptName, 'Migrate', 'Migrated forward ' + toRun.length + ' step(s). State now at version ' + html.bold(currentVersion) + '.'); + } else { + // Backward โ€” use stored down strings, run on working copy + var storedMigrations = state[SCRIPT_NAME].migrations[scriptName] || {}; + var versions = Object.keys(storedMigrations).sort(compareSemver); + var toRollBack = versions.filter(v => compareSemver(v, currentVersion) > 0 && compareSemver(v, storedVersion) <= 0).reverse(); + + if (toRollBack.length === 0) { + replyError(msg, scriptName, 'No stored rollback migrations available. State cannot be reverted.'); + return; + } + + var stateRef = reg.stateRef || state[scriptName] || {}; + var working = JSON.parse(JSON.stringify(stateRef)); + for (let i = 0; i < toRollBack.length; i++) { + const v = toRollBack[i]; + const downStr = storedMigrations[v]; + if (!downStr) { + replyError(msg, scriptName, 'Migration ' + v + ' has no stored rollback function. Cannot continue.'); + return; + } + try { + var downFn = (new Function('return ' + downStr))(); + downFn(working); + } catch (e) { + replyError(msg, scriptName, 'Rollback of ' + v + ' failed: ' + e.message + '. State unchanged (version ' + storedVersion + ').'); + if (typeof reg.onMigrationFailure === 'function') { + reg.onMigrationFailure({ version: v, direction: 'down', error: e, currentStoredVersion: storedVersion }); + } + return; + } + } + // Commit โ€” overwrite state and clean up stored migrations + Object.keys(stateRef).forEach(k => delete stateRef[k]); + Object.assign(stateRef, working); + toRollBack.forEach(v => delete storedMigrations[v]); + state[SCRIPT_NAME].versions[scriptName] = currentVersion; + reply(msg, scriptName, 'Migrate', 'Rolled back ' + toRollBack.length + ' migration(s). State now at version ' + html.bold(currentVersion) + '.'); + } + }; + + /** + * Register an example for a script. + * + * @param {string} targetScript Script this example belongs to (must be registered, or will queue). + * @param {string} registeredBy Script providing this example. + * @param {object} struct Example definition: + * @param {string} struct.name Example name (unique per script). + * @param {string} [struct.description] Human-readable description. + * @param {object} [struct.handout] Default handler fields: { notes, gmnotes, avatar, archived, inplayerjournals } + * @param {array} [struct.guide] Array of guide step objects. + * ... any additional script-specific fields passed to exampleHandler. + */ + const registerExample = (targetScript, registeredBy, struct) => { + if (!struct || !struct.name) { + log(SCRIPT_NAME + ': registerExample requires a name.'); + return false; + } + if (!struct.handout && (!struct.guide || struct.guide.length === 0)) { + log(SCRIPT_NAME + ': registerExample "' + struct.name + '" requires a handout, a guide, or both.'); + return false; + } + + // Queue if target not yet registered + if (!registrations[targetScript]) { + if (!pendingExamples[targetScript]) pendingExamples[targetScript] = []; + pendingExamples[targetScript].push({ registeredBy, struct }); + return true; + } + + const key = targetScript + '/' + struct.name; + if (examples[key]) return false; + examples[key] = Object.assign({ source: registeredBy, target: targetScript }, struct); + return true; + }; + + // ========================================================================= + // Input Handling + // ========================================================================= + + /** + * Attempt to handle a chat message. Returns true if consumed, false otherwise. + * Call this early in your script's chat handler: + * if (Tutorial.handleInput(msg)) return; + */ + const handleInput = (msg) => { + if (msg.type !== 'api') return false; + const firstWord = msg.content.split(' ')[0]; + + // Find which registered script this command belongs to + const entry = Object.entries(registrations).find(e => e[1].command === firstWord); + if (!entry) return false; + + const [scriptName, reg] = entry; + const content = msg.content.slice(reg.command.length).trim(); + + // Parse args (quote-aware) + const args = []; + var argRx = /"([^"]*)"|'([^']*)'|`([^`]*)`|(\S+)/g; + var m; + while ((m = argRx.exec(content)) !== null) { + args.push(m[1] !== undefined ? m[1] : m[2] !== undefined ? m[2] : m[3] !== undefined ? m[3] : m[4]); + } + const cmd = (args.shift() || '').toLowerCase(); + + // Internal subcommands (ScriptKit-only, not alias-routed) + if (scriptName === SCRIPT_NAME && cmd === 'ping') { + doPing(msg, args.join(' ')); + return true; + } + if (scriptName === SCRIPT_NAME && cmd === 'dismiss-whatsnew') { + ensureState(); + Object.keys(registrations).forEach(name => { + if (registrations[name].version) state[SCRIPT_NAME].lastSeenVersions[name] = registrations[name].version; + }); + reply(msg, SCRIPT_NAME, 'What\'s New', 'Dismissed. You won\'t see these changes again until the next update.'); + return true; + } + if (scriptName === SCRIPT_NAME && cmd === 'motd') { + // Show a random motd from a specific plugin or the global pool + var targetPlugin = args.length > 0 ? args.join(' ') : null; + if (targetPlugin) { + // Specific plugin + var targetReg = registrations[targetPlugin]; + if (!targetReg || !targetReg.motd || targetReg.motd.length === 0) { + reply(msg, SCRIPT_NAME, 'Tip', 'No tips registered for ' + html.escape(targetPlugin) + '.'); + return true; + } + var tip = pickMotd(targetPlugin, targetReg.motd); + if (tip) reply(msg, SCRIPT_NAME, 'Tip', renderMotdCard(targetPlugin, tip, targetReg, '!scriptkit motd ' + targetPlugin)); + } else { + // Global pool โ€” pick a random plugin that has motds, then pick from it + var pluginsWithMotd = Object.keys(registrations).filter(n => registrations[n].motd && registrations[n].motd.length > 0); + if (pluginsWithMotd.length === 0) { + reply(msg, SCRIPT_NAME, 'Tip', 'No tips registered across any plugins.'); + return true; + } + var randomPlugin = pluginsWithMotd[Math.floor(Math.random() * pluginsWithMotd.length)]; + var rReg = registrations[randomPlugin]; + var tip = pickMotd(randomPlugin, rReg.motd); + if (tip) reply(msg, SCRIPT_NAME, 'Tip', renderMotdCard(randomPlugin, tip, rReg, '!scriptkit motd')); + } + return true; + } + if (scriptName === SCRIPT_NAME && cmd === 'whatsnew') { + // Parse optional date argument + var sinceDate = null; + if (args.length > 0) { + sinceDate = parseDate(args.join(' ')); + if (!sinceDate) { + reply(msg, SCRIPT_NAME, 'Error', 'Could not parse date: "' + html.escape(args.join(' ')) + '". Use ISO (2026-08-01) or human-readable (August 12, 2026).'); + return true; + } + } + + // Consolidated whatsnew across all registered plugins + let out = html.bold('What\'s New โ€” All Plugins'); + if (sinceDate) out += ' ' + html.small('since ' + new Date(sinceDate).toLocaleDateString()); + out += html.paragraph(''); + let hasContent = false; + var dateArg = sinceDate ? ' ' + args.join(' ') : ''; + Object.keys(registrations).forEach(name => { + const r = registrations[name]; + if (name === SCRIPT_NAME || !r.help || !r.version) return; + var relevant = getRelevantChangelog(name, r, sinceDate); + if (relevant.length === 0) return; + hasContent = true; + out += html.bold(html.escape(name) + ' v' + r.version) + html.br(); + var changes = []; + relevant.forEach(e => { if (Array.isArray(e.changes)) changes.push(...e.changes); }); + if (changes.length > 0) out += html.list(changes.slice(0, 5).map(c => html.escape(c))); + if (changes.length > 5) out += html.small('...and ' + (changes.length - 5) + ' more') + html.br(); + out += html.button('See Details', r.command + ' ' + (r.aliases.whatsnew || 'whatsnew') + dateArg) + html.paragraph(''); + }); + if (!hasContent) { + out += sinceDate + ? 'No changes since ' + new Date(sinceDate).toLocaleDateString() + '.' + : 'Nothing new across any registered plugins.'; + } + reply(msg, SCRIPT_NAME, 'What\'s New', out); + return true; + } + + // Alias matcher โ€” supports string or array of strings + const matchAlias = (alias) => { + if (!alias) return false; + if (Array.isArray(alias)) return alias.some(a => a === cmd); + return alias === cmd; + }; + + // Check aliases + if (matchAlias(reg.aliases.help)) { + showHelp(msg, scriptName, reg, args); + return true; + } + if (matchAlias(reg.aliases.man)) { + showMan(msg, scriptName, reg, args); + return true; + } + if (matchAlias(reg.aliases.whatsnew)) { + showWhatsNew(msg, scriptName, reg, args); + return true; + } + if (matchAlias(reg.aliases.changes)) { + showChanges(msg, scriptName, reg, args); + return true; + } + if (matchAlias(reg.aliases.motd)) { + var motdArr = reg.motd; + if (motdArr && motdArr.length > 0) { + var tip = pickMotd(scriptName, motdArr); + if (tip) { + var card = renderMotdCard(scriptName, tip, reg); + reply(msg, scriptName, 'Tip', card); + } + } else { + reply(msg, scriptName, 'Tip', 'No tips registered for ' + scriptName + '.'); + } + return true; + } + if (matchAlias(reg.aliases.genHelp)) { + generateHelpHandout(msg, scriptName, reg, 'usr'); + return true; + } + if (matchAlias(reg.aliases.genDev)) { + generateHelpHandout(msg, scriptName, reg, 'dev'); + return true; + } + if (matchAlias(reg.aliases.examples)) { + showExamples(msg, scriptName, reg, args); + return true; + } + if (matchAlias(reg.aliases.generate)) { + generateExample(msg, scriptName, reg, args); + return true; + } + if (matchAlias(reg.aliases.guide)) { + startGuideByName(msg, scriptName, reg, args); + return true; + } + if (matchAlias(reg.aliases.guideContinue)) { + handleGuideContinue(msg, args[0], args.slice(1)); + return true; + } + if (matchAlias(reg.aliases.guideBack)) { + handleGuideBack(msg, args[0]); + return true; + } + if (matchAlias(reg.aliases.guideCancel)) { + handleGuideCancel(msg, args[0]); + return true; + } + if (matchAlias(reg.aliases.migrate)) { + handleMigrateCommand(msg, scriptName, reg, args); + return true; + } + + return false; + }; + + // ========================================================================= + // Help & Man + // ========================================================================= + + /** + * Show concise help (command overview). + */ + const showHelp = (msg, scriptName, reg, args) => { + const helpData = reg.help; + if (!helpData) { + reply(msg, scriptName, 'Help', 'No help registered for ' + scriptName + '.'); + return; + } + + const search = args.length > 0 ? args.join(' ').toLowerCase() : null; + const version = reg.version; + + let out = ''; + var helpDesc = resolve(helpData.description); + if (helpDesc) { + out += html.bold(html.escape(scriptName)); + if (version) out += ' ' + html.small(html.bold('v' + version)); + out += html.paragraph(html.small(helpDesc)) + html.line(); + } + + // Commands + if (helpData.commands && helpData.commands.length > 0) { + // Auto-inject ScriptKit-managed commands at the top + var helpAlias = Array.isArray(reg.aliases.help) ? reg.aliases.help[0] : reg.aliases.help; + var manAlias = Array.isArray(reg.aliases.man) ? reg.aliases.man[0] : reg.aliases.man; + var autoCommands = []; + if (helpAlias) autoCommands.push({ syntax: helpAlias, description: 'Show this help' }); + if (manAlias && helpData.topics && Object.keys(helpData.topics).length > 0) autoCommands.push({ syntax: manAlias + ' [topic]', description: 'Detailed help by topic' }); + if (reg.aliases.examples) autoCommands.push({ syntax: reg.aliases.examples, description: 'Browse examples' }); + if (reg.aliases.whatsnew) autoCommands.push({ syntax: reg.aliases.whatsnew, description: 'Show what\'s new in this version' }); + if (reg.aliases.changes) autoCommands.push({ syntax: reg.aliases.changes + ' [search]', description: 'Full changelog (filterable)' }); + if (reg.aliases.motd && reg.motd && reg.motd.length > 0) autoCommands.push({ syntax: reg.aliases.motd, description: 'Show a random tip' }); + if (reg.aliases.genHelp) autoCommands.push({ syntax: reg.aliases.genHelp, description: 'Regenerate help handout' }); + if (reg.aliases.genDev) autoCommands.push({ syntax: reg.aliases.genDev, description: 'Generate dev docs handout' }); + + var renderCommands = function(items, search, version, reg) { + var out = ''; + items.forEach(c => { + if (c.group) { + var groupContent = renderCommands(c.commands || [], search, version, reg); + if (groupContent) { + out += html.paragraph(html.bold(html.escape(c.group) + ':') + html.br() + groupContent); + } + } else { + if (c.deleted) return; + var cDesc = resolve(c.description) || ''; + if (search && (c.syntax || '').toLowerCase().indexOf(search) === -1 && + cDesc.toLowerCase().indexOf(search) === -1) return; + var vTag = ''; + if (isNewVersion(c.version, reg)) vTag = ' ' + html.newBadge(); + if (c.deprecated) vTag = ' ' + html.deprecatedBadge(); + out += html.code(reg.command + ' ' + c.syntax) + vTag + ' โ€” ' + html.escape(cDesc) + html.br(); + } + }); + return out; + }; + + // Render auto commands + var autoOutput = renderCommands(autoCommands, search, version, reg); + if (autoOutput) out += html.paragraph(autoOutput); + + // Render user commands + var cmdOutput = renderCommands(helpData.commands, search, version, reg); + if (cmdOutput) { + out += html.bold('Commands:') + html.br() + cmdOutput; + } + } + + // Topics button (if man is available โ€” via alias or user-registered command) + if (helpData.topics && Object.keys(helpData.topics).filter(k => helpData.topics[k] && !helpData.topics[k].deleted).length > 0) { + var manCmd = reg.aliases.man ? (Array.isArray(reg.aliases.man) ? reg.aliases.man[0] : reg.aliases.man) : null; + // If man alias was auto-nulled but a user command handles it, find that command word + if (!manCmd && helpData.commands) { + var flatCmds = []; + var flattenC = (items) => { items.forEach(c => { if (c.group) flattenC(c.commands || []); else flatCmds.push(c); }); }; + flattenC(helpData.commands); + var manEntry = flatCmds.find(c => !c.deleted && c.syntax && c.syntax.split(' ')[0].toLowerCase() === 'man'); + if (manEntry) manCmd = manEntry.syntax.split(' ')[0]; + } + if (manCmd) out += html.line() + html.button('๐Ÿ“– Browse Topics', reg.command + ' ' + manCmd); + } + + if (!out) { + out = 'No results for "' + html.escape(search) + '".'; + } + + reply(msg, scriptName, 'Help', out); + }; + + /** + * Show man page (detailed topic lookup with tiered search). + */ + const showMan = (msg, scriptName, reg, args) => { + const helpData = reg.help; + if (!helpData || !helpData.topics) { + reply(msg, scriptName, 'Man', 'No documentation registered for ' + scriptName + '.'); + return; + } + + const topics = helpData.topics; + const search = args.length > 0 ? args.join(' ').toLowerCase() : null; + var manCmd = Array.isArray(reg.aliases.man) ? reg.aliases.man[0] : reg.aliases.man; + + // No search โ€” list all topics + if (!search) { + let out = html.bold(html.escape(scriptName) + ' โ€” Topics:') + html.paragraph(''); + Object.entries(topics).forEach(([key, t]) => { + if (!t || t.deleted) return; + out += 'โ€ข ' + html.button(resolve(t.title) || key, reg.command + ' ' + manCmd + ' ' + key); + var tDesc = resolve(t.description); + if (tDesc) out += ' โ€” ' + html.escape(tDesc); + out += html.br(); + }); + reply(msg, scriptName, 'Man', out); + return; + } + + // Tier 1: exact topic key match + if (topics[search] && !topics[search].deleted) { + renderManTopic(msg, scriptName, reg, search, topics[search]); + return; + } + + // Tier 1a: command lookup by first word of syntax + if (helpData.commands) { + var flatCmds = []; + var flattenC = (items) => { items.forEach(c => { if (c.group) flattenC(c.commands || []); else flatCmds.push(c); }); }; + flattenC(helpData.commands); + var cmdMatch = flatCmds.find(c => !c.deleted && c.items && c.syntax.split(' ')[0].toLowerCase() === search); + if (cmdMatch) { + renderManCommand(msg, scriptName, reg, cmdMatch); + return; + } + } + + // Tier 1b: exact title match + const titleMatch = Object.entries(topics).find(([k, t]) => + t && !t.deleted && (resolve(t.title) || k).toLowerCase() === search + ); + if (titleMatch) { + renderManTopic(msg, scriptName, reg, titleMatch[0], titleMatch[1]); + return; + } + + // Tier 2: search within topic items/body + const results = []; + Object.entries(topics).forEach(([key, t]) => { + if (!t || t.deleted) return; + // Check items array if present + var tItems = resolve(t.items); + if (tItems && Array.isArray(tItems)) { + tItems.forEach(item => { + const nameMatch = (resolve(item.name) || '').toLowerCase().indexOf(search) !== -1; + const descMatch = (resolve(item.description) || '').toLowerCase().indexOf(search) !== -1; + if (nameMatch || descMatch) { + results.push({ topicKey: key, topic: t, item: item, exact: nameMatch }); + } + }); + } + // Check body text + var bodyText = resolve(t.body) || ''; + if (bodyText && bodyText.toLowerCase().indexOf(search) !== -1) { + results.push({ topicKey: key, topic: t, item: null, exact: false }); + } + // Check title/description + if ((resolve(t.title) || '').toLowerCase().indexOf(search) !== -1 || + (resolve(t.description) || '').toLowerCase().indexOf(search) !== -1) { + results.push({ topicKey: key, topic: t, item: null, exact: true }); + } + }); + + // Also search command items + var flatCommands = []; + var flattenCmds = (items) => { items.forEach(c => { if (c.group) flattenCmds(c.commands || []); else flatCommands.push(c); }); }; + if (helpData.commands) flattenCmds(helpData.commands); + flatCommands.forEach(c => { + if (c.deleted || !c.items) return; + var cmdKey = c.syntax.split(' ')[0]; + var cItems = resolve(c.items); + if (!cItems) return; + cItems.forEach(item => { + const nameMatch = (resolve(item.name) || '').toLowerCase().indexOf(search) !== -1; + const descMatch = (resolve(item.description) || '').toLowerCase().indexOf(search) !== -1; + if (nameMatch || descMatch) { + results.push({ topicKey: cmdKey, topic: { title: reg.command + ' ' + c.syntax, description: resolve(c.description) }, item: item, exact: nameMatch, _cmd: c }); + } + }); + }); + + if (results.length === 0) { + reply(msg, scriptName, 'Man', 'No results for "' + html.escape(search) + '".'); + return; + } + + // If exactly one topic matched, show it fully + const uniqueTopics = [...new Set(results.map(r => r.topicKey))]; + if (uniqueTopics.length === 1 && results.some(r => r.exact)) { + renderManTopic(msg, scriptName, reg, uniqueTopics[0], topics[uniqueTopics[0]]); + return; + } + + // Multiple matches โ€” show condensed results with links + let out = html.bold('Results for "' + html.escape(search) + '":') + html.paragraph(''); + const shown = new Set(); + results.sort((a, b) => (b.exact ? 1 : 0) - (a.exact ? 1 : 0)); + results.forEach(r => { + if (shown.has(r.topicKey + '/' + (r.item ? resolve(r.item.name) : ''))) return; + shown.add(r.topicKey + '/' + (r.item ? resolve(r.item.name) : '')); + if (r.item) { + out += 'โ€ข ' + html.bold(html.escape(resolve(r.item.name))); + var rItemDesc = resolve(r.item.description); + if (rItemDesc) out += ' โ€” ' + html.escape(rItemDesc).slice(0, 80); + var inLabel = r._cmd ? 'Commands: ' + r.topicKey : html.escape(resolve(r.topic.title) || r.topicKey); + out += html.br() + html.indent(2) + html.italic('in ' + html.button(inLabel, reg.command + ' ' + manCmd + ' ' + r.topicKey)) + html.br(); + } else { + out += 'โ€ข ' + html.button(html.escape(resolve(r.topic.title) || r.topicKey), reg.command + ' ' + manCmd + ' ' + r.topicKey); + var rTopicDesc = resolve(r.topic.description); + if (rTopicDesc) out += ' โ€” ' + html.escape(rTopicDesc); + out += html.br(); + } + }); + reply(msg, scriptName, 'Man', out); + }; + + /** + * Render a full man topic. + */ + const renderManTopic = (msg, scriptName, reg, key, topic) => { + let out = html.bold(html.escape(resolve(topic.title) || key)); + if (topic.version) out += ' ' + html.italic('(v' + topic.version + ')'); + // Link to handout section if handout exists + var helpHandout = reg._handouts && reg._handouts.usr; + if (helpHandout) out += ' ' + html.handoutLink('๐Ÿ“–', helpHandout.get('id'), null, resolve(topic.title) || key); + out += html.br(); + var topicDesc = resolve(topic.description); + if (topicDesc) out += html.escape(topicDesc) + html.br(); + out += html.br(); + + // Render body (supports string or function) + if (topic.body) { + var body = resolve(topic.body); + out += html.render(body) + html.paragraph(''); + } + + // Render items (structured entries within a topic) + var topicItems = resolve(topic.items); + if (topicItems && Array.isArray(topicItems)) { + topicItems.forEach(item => { + if (resolve(item.deleted)) return; + var vTag = ''; + if (item.version && isNewVersion(item.version, reg)) vTag = ' ' + html.newBadge(); + if (resolve(item.deprecated)) vTag = ' ' + html.deprecatedBadge(); + out += html.bold(html.escape(resolve(item.name))) + vTag; + var itemSyntax = resolve(item.syntax); + if (itemSyntax) out += ' ' + html.code(itemSyntax); + out += html.br(); + var itemDesc = resolve(item.description); + if (itemDesc) out += html.render(itemDesc) + html.br(); + out += html.br(); + }); + } + + reply(msg, scriptName, 'Man', out); + }; + + /** + * Render a command's man page (details + items). + */ + const renderManCommand = (msg, scriptName, reg, cmd) => { + let out = html.bold(html.code(reg.command + ' ' + cmd.syntax)); + if (cmd.version) out += ' ' + html.italic('(v' + cmd.version + ')'); + out += html.br(); + var cmdDesc = resolve(cmd.description); + if (cmdDesc) out += html.escape(cmdDesc) + html.br(); + var cmdDetails = resolve(cmd.details); + if (cmdDetails) out += html.br() + html.render(cmdDetails) + html.br(); + out += html.br(); + + var cmdItems = resolve(cmd.items); + if (cmdItems) { + cmdItems.forEach(item => { + if (resolve(item.deleted)) return; + var vTag = ''; + if (isNewVersion(item.version, reg)) vTag = ' ' + html.newBadge(); + if (resolve(item.deprecated)) vTag = ' ' + html.deprecatedBadge(); + out += html.bold(html.escape(resolve(item.name))) + vTag; + out += html.br(); + var itemDesc = resolve(item.description); + if (itemDesc) out += html.render(itemDesc) + html.br(); + out += html.br(); + }); + } + + reply(msg, scriptName, 'Man', out); + }; + + /** + * Parse a date string (ISO or human-readable). Returns timestamp or null. + */ + const parseDate = (str) => { + if (!str) return null; + var t = new Date(str).getTime(); + return isNaN(t) ? null : t; + }; + + /** + * Get relevant changelog entries for a plugin, filtered by date or version. + * @param {object} reg - registration object + * @param {number|null} sinceDate - timestamp threshold, or null for version-based + * @returns {Array} matching changelog entries + */ + const getRelevantChangelog = (scriptName, reg, sinceDate) => { + const helpData = reg.help; + if (!helpData || !helpData.changelog) return []; + ensureState(); + var pluginDates = (state[SCRIPT_NAME].versionDates || {})[scriptName] || {}; + if (sinceDate) { + return helpData.changelog.filter(e => { + var d = pluginDates[e.version]; + return d && d >= sinceDate; + }); + } + return helpData.changelog.filter(e => isNewVersion(e.version, reg)); + }; + + /** + * Show what's new in current version (chat command). + */ + const showWhatsNew = (msg, scriptName, reg, args) => { + const helpData = reg.help; + if (!helpData) { + reply(msg, scriptName, 'What\'s New', 'No help data registered.'); + return; + } + + // Parse optional date argument + var sinceDate = null; + if (args && args.length > 0) { + sinceDate = parseDate(args.join(' ')); + if (!sinceDate) { + reply(msg, scriptName, 'Error', 'Could not parse date: "' + html.escape(args.join(' ')) + '".'); + return; + } + } + + const version = reg.version; + const commands = helpData.commands || []; + const topics = helpData.topics || {}; + + let out = html.bold(html.escape(scriptName) + ' โ€” What\'s New'); + if (sinceDate) out += ' ' + html.small('since ' + new Date(sinceDate).toLocaleDateString()); + out += html.paragraph(''); + + // Changelog entries + var relevant = getRelevantChangelog(scriptName, reg, sinceDate); + if (relevant.length > 0) { + relevant.forEach(e => { + out += html.bold('v' + e.version) + html.br(); + if (Array.isArray(e.changes)) { + out += html.list(e.changes.map(c => html.escape(c))); + } else if (e.changes) { + out += html.escape(e.changes) + html.br(); + } + }); + out += html.line(); + } + + // Auto-detected new items (only for version-based mode) + if (!sinceDate) { + var newItems = []; + var flatCommands = []; + var flattenCmds = (items) => { items.forEach(c => { if (c.group) flattenCmds(c.commands || []); else flatCommands.push(c); }); }; + flattenCmds(commands); + flatCommands.forEach(c => { + if (!c.deleted && isNewVersion(c.version, reg)) { + newItems.push(html.code(reg.command + ' ' + c.syntax) + ' โ€” ' + html.escape(resolve(c.description) || '')); + } + }); + Object.entries(topics).forEach(([k, t]) => { + if (!t || t.deleted) return; + if (isNewVersion(t.version, reg)) { + newItems.push(html.bold(resolve(t.title) || k) + (resolve(t.description) ? ' โ€” ' + html.escape(resolve(t.description)) : '')); + } + var tItems = resolve(t.items); + if (tItems) { + tItems.forEach(item => { + if (!resolve(item.deleted) && isNewVersion(item.version, reg) && !isNewVersion(t.version, reg)) { + newItems.push(html.bold(resolve(item.name)) + ' in ' + html.italic(resolve(t.title) || k) + (resolve(item.description) ? ' โ€” ' + resolve(item.description) : '')); + } + }); + } + }); + if (newItems.length > 0) { + out += html.bold('New Features:') + html.br(); + out += html.list(newItems); + } + } + + if (out.indexOf('
  • ') === -1 && out.indexOf('
    ') === -1) { + out += sinceDate + ? 'No changes since ' + new Date(sinceDate).toLocaleDateString() + '.' + : 'Nothing new since last version.'; + } + + reply(msg, scriptName, 'What\'s New', out); + }; + + /** + * Show full changelog for a script (chat command). Optional search filter. + */ + const showChanges = (msg, scriptName, reg, args) => { + const helpData = reg.help; + if (!helpData || !helpData.changelog || helpData.changelog.length === 0) { + reply(msg, scriptName, 'Changes', 'No changelog registered.'); + return; + } + + const search = args && args.length > 0 ? args.join(' ').toLowerCase() : null; + const changelog = helpData.changelog; + + let out = html.bold(html.escape(scriptName) + ' โ€” Changelog'); + if (search) out += ' ' + html.small('(filter: "' + html.escape(search) + '")'); + out += html.paragraph(''); + + let hasResults = false; + changelog.forEach(entry => { + const changes = Array.isArray(entry.changes) ? entry.changes : (entry.changes ? [entry.changes] : []); + let matchingChanges; + if (search) { + // Match on version or change text + const versionMatch = entry.version.toLowerCase().indexOf(search) !== -1; + matchingChanges = versionMatch ? changes : changes.filter(c => c.toLowerCase().indexOf(search) !== -1); + if (matchingChanges.length === 0) return; + } else { + matchingChanges = changes; + } + hasResults = true; + var dateStr = entry.date ? ' ' + html.small('(' + entry.date + ')') : ''; + out += html.bold('v' + entry.version) + dateStr + html.br(); + out += html.list(matchingChanges.map(c => html.escape(c))); + }); + + if (!hasResults) { + out += 'No changes matching "' + html.escape(search) + '".'; + } + + reply(msg, scriptName, 'Changes', out); + }; + + // ========================================================================= + // Handout Generation + // ========================================================================= + + /** + * Generate a help or dev handout from the registered help data. + * @param {object|null} msg Chat message (null for auto-generation on startup) + * @param {string} scriptName + * @param {object} reg Registration object + * @param {string} mode 'usr' or 'dev' + */ + const generateHelpHandout = (msg, scriptName, reg, mode) => { + const helpData = reg.help; + if (!helpData) { + if (msg) reply(msg, scriptName, 'Error', 'No help data registered.'); + return; + } + + const version = reg.version; + const handoutName = 'Help: ' + scriptName + (mode === 'dev' ? '/Dev' : ''); + const topics = helpData.topics || {}; + const commands = helpData.commands || []; + + // Filter topics by handouts field + const matchesMode = (t) => { + var h = t.handouts !== undefined ? t.handouts : 'usr'; + if (h === null) return false; + if (Array.isArray(h)) return h.indexOf(mode) !== -1; + return h === mode; + }; + + // Auto-inject ScriptKit examples topic for dev handout unless explicitly set to null + if (mode === 'dev' && !topics.hasOwnProperty('scriptKit')) { + topics.scriptKit = { + title: 'Registering Examples', + description: 'How to register examples for ' + scriptName + ' that show off your script\'s integration', + handouts: 'dev', + body: 'Other scripts can register examples targeting **' + scriptName + '**:\n\n' + + '```ScriptKit.' + scriptName + '.registerExample(\'YourScript\', {\n' + + ' name: \'example-name\',\n' + + ' description: \'What this example does\',\n' + + ' guide: [ /* guide steps */ ],\n' + + ' handout: { notes: \'...\' }\n' + + '});```\n\n' + + 'See the ScriptKit wiki or run `!scriptkit ' + registrations[SCRIPT_NAME].aliases.genDev + '` for more information on examples and guides.', + }; + } + + const filteredTopicKeys = Object.keys(topics).filter(k => topics[k] && !topics[k].deleted && matchesMode(topics[k])); + + // Build "What's New" section (items matching current version) + var whatsNew = []; + var whatsNewSeen = {}; + var flatCmds = []; + var flattenC = (items) => { items.forEach(c => { if (c.group) flattenC(c.commands || []); else flatCmds.push(c); }); }; + flattenC(commands); + flatCmds.forEach(c => { + if (!c.deleted && isNewVersion(c.version, reg)) whatsNew.push(html.code(reg.command + ' ' + c.syntax) + ' โ€” ' + html.escape(resolve(c.description) || '')); + var cItems = resolve(c.items); + if (cItems) { + cItems.forEach(item => { + if (!resolve(item.deleted) && isNewVersion(item.version, reg) && !isNewVersion(c.version, reg)) { + var iName = resolve(item.name); + if (!whatsNewSeen[iName]) { + whatsNewSeen[iName] = true; + whatsNew.push(html.code(iName) + ' in ' + html.code(reg.command + ' ' + c.syntax) + ' โ€” ' + html.escape(resolve(item.description) || '')); + } + } + }); + } + }); + filteredTopicKeys.forEach(k => { + var t = topics[k]; + if (isNewVersion(t.version, reg)) whatsNew.push(html.bold(resolve(t.title) || k) + (resolve(t.description) ? ' โ€” ' + html.escape(resolve(t.description)) : '')); + var tItems = resolve(t.items); + if (tItems) { + tItems.forEach(item => { + var iName = resolve(item.name); + if (!resolve(item.deleted) && isNewVersion(item.version, reg) && !whatsNewSeen[iName]) { + whatsNewSeen[iName] = true; + whatsNew.push(html.bold(iName) + (resolve(item.description) ? ' โ€” ' + resolve(item.description) : '')); + } + }); + } + }); + + // Build "Removed" section (deleted items) + var removed = []; + commands.forEach(c => { + if (c.deleted) removed.push(html.code(reg.command + ' ' + c.syntax) + ' โ€” removed in v' + c.deleted); + }); + Object.keys(topics).forEach(k => { + var t = topics[k]; + if (!t) return; + if (t.deleted) removed.push(html.bold(resolve(t.title) || k) + ' โ€” removed in v' + t.deleted); + var tItems = resolve(t.items); + if (tItems) { + tItems.forEach(item => { + if (resolve(item.deleted)) removed.push(html.bold(resolve(item.name)) + ' โ€” removed in v' + resolve(item.deleted)); + }); + } + }); + + // Render HTML + var out = ''; + out += '

    ' + html.escape(scriptName) + (version ? ' v' + version : '') + '

    '; + var handoutDesc = resolve(helpData.description); + if (handoutDesc) out += '

    ' + html.render(handoutDesc) + '

    '; + + // Quick Start / Examples callout (user handout only) + var hasExamples = reg.aliases && reg.aliases.examples && Object.values(examples).filter(e => e.target === scriptName).length > 0; + var examplesBtn = hasExamples ? ' ' + html.button('๐Ÿ“– Examples', reg.command + ' ' + reg.aliases.examples, { background: '#444', fontSize: '11px' }) : ''; + if (mode === 'usr') { + if (helpData.quickStart) { + out += '

    Quick Start' + examplesBtn + '

    '; + var qs = helpData.quickStart; + if (typeof qs === 'function') qs = qs(); + if (Array.isArray(qs)) out += html.orderedList(qs); + else out += '
    ' + qs + '
    '; + } else if (hasExamples) { + out += '

    ' + examplesBtn + ' โ€” ready-made examples you can install and learn from.

    '; + } + } + + // What's New + if (helpData.changelog && helpData.changelog.length > 0) { + var relevant = helpData.changelog.filter(e => isNewVersion(e.version, reg)); + relevant.forEach(e => { + if (Array.isArray(e.changes)) { + e.changes.forEach(c => whatsNew.push(html.escape(c))); + } else if (e.changes) { + whatsNew.push(html.escape(e.changes)); + } + }); + } + if (whatsNew.length > 0) { + out += '

    What\'s New in v' + version + '

    '; + out += html.list(whatsNew); + } + + // Commands (usr handout only) + if (mode === 'usr' && commands.length > 0) { + var renderCmdsHandout = function(items) { + var result = ''; + items.forEach(c => { + if (c.group) { + var inner = renderCmdsHandout(c.commands || []); + if (inner) result += '

    ' + html.escape(c.group) + '

    ' + inner; + } else { + if (c.deleted) return; + var badge = ''; + var cmdIsNew = isNewVersion(c.version, reg); + if (cmdIsNew) badge = ' ' + html.newBadge(); + if (c.deprecated) badge = ' ' + html.deprecatedBadge(); + if (mode === 'dev' && c.version) badge += ' ' + html.version(c.version); + result += '

    ' + html.code(reg.command + ' ' + c.syntax) + ' โ€” ' + html.escape(resolve(c.description) || '') + badge + '

    '; + var cDetails = resolve(c.details); + if (cDetails) result += '

    ' + html.render(cDetails) + '

    '; + var cItems = resolve(c.items); + if (cItems) { + result += '
      '; + cItems.forEach(item => { + if (resolve(item.deleted)) return; + var iBadge = ''; + if (!cmdIsNew && isNewVersion(item.version, reg)) iBadge = ' ' + html.newBadge(); + if (resolve(item.deprecated)) iBadge = ' ' + html.deprecatedBadge(); + if (mode === 'dev' && item.version) iBadge += ' ' + html.version(item.version); + result += '
    • ' + html.code(resolve(item.name)) + ' โ€” ' + html.escape(resolve(item.description) || '') + iBadge + '
    • '; + }); + result += '
    '; + } + } + }); + return result; + }; + out += '

    Commands

    '; + out += renderCmdsHandout(commands); + } + + // Topics + filteredTopicKeys.forEach(k => { + var t = topics[k]; + var badge = ''; + var topicIsNew = isNewVersion(t.version, reg); + if (topicIsNew) badge = ' ' + html.newBadge(); + if (t.deprecated) badge = ' ' + html.deprecatedBadge(); + if (mode === 'dev' && t.version) badge += ' ' + html.version(t.version); + out += '

    ' + html.escape(resolve(t.title) || k) + badge + '

    '; + var tDesc = resolve(t.description); + if (tDesc) out += '

    ' + html.escape(tDesc) + '

    '; + var tDetails = resolve(t.details); + if (tDetails) out += '

    ' + html.render(tDetails) + '

    '; + if (t.body) { + var body = resolve(t.body); + out += '
    ' + html.render(body) + '
    '; + } + var tItems = resolve(t.items); + if (tItems) { + tItems.forEach(item => { + if (resolve(item.deleted)) return; + var iBadge = ''; + if (!topicIsNew && isNewVersion(item.version, reg)) iBadge = ' ' + html.newBadge(); + if (resolve(item.deprecated)) iBadge = ' ' + html.deprecatedBadge(); + if (mode === 'dev' && item.version) iBadge += ' ' + html.version(item.version); + out += '

    ' + html.bold(html.escape(resolve(item.name))) + iBadge; + var itemSyntax = resolve(item.syntax); + if (itemSyntax) out += ' ' + html.code(itemSyntax); + out += '

    '; + var itemDesc = resolve(item.description); + if (itemDesc) out += '

    ' + html.render(itemDesc) + '

    '; + var itemDetails = resolve(item.details); + if (itemDetails) out += '

    ' + html.render(itemDetails) + '

    '; + }); + } + }); + + // Removed section + if (removed.length > 0) { + out += '

    Removed

    '; + out += html.list(removed); + } + + // Examples footer + if (reg.aliases && reg.aliases.examples) { + var examplesForScript = Object.values(examples).filter(e => e.target === scriptName); + if (examplesForScript.length > 0) { + out += '

    ' + html.button('๐Ÿ“– Browse Examples', reg.command + ' ' + reg.aliases.examples, { background: '#444', fontSize: '11px' }) + ' โ€” ' + examplesForScript.length + ' ready-made example' + (examplesForScript.length === 1 ? '' : 's') + ' available.

    '; + } + } + + // Create/update handout + var handout = findObjs({ type: 'handout', name: handoutName })[0]; + if (!handout) { + handout = createObj('handout', { + name: handoutName, + inplayerjournals: '', + archived: false, + avatar: 'https://files.d20.io/images/127392204/tAiDP73rpSKQobEYm5QZUw/thumb.png?15878425385', + }); + } + setHandoutNotes(handout, out); + // Cache the handout reference + if (reg._handouts) reg._handouts[mode === 'dev' ? 'dev' : 'usr'] = handout; + + if (msg) { + reply(msg, scriptName, mode === 'dev' ? 'Dev Docs' : 'Help', + 'Generated ' + html.bold(handoutName) + '. ' + html.handoutLink('[Open]', handout.get('id'))); + } else { + log(SCRIPT_NAME + ': Generated ' + handoutName + ' for ' + scriptName); + } + }; + + // ========================================================================= + // Examples Listing + // ========================================================================= + + const showExamples = (msg, scriptName, reg, args) => { + // Parse search filters + let pluginFilter = null, nameFilter = null, descFilter = null; + const filterArgs = []; + args.forEach(a => { + if (a.toLowerCase().startsWith('script:')) pluginFilter = a.slice(7).toLowerCase(); + else if (a.toLowerCase().startsWith('name:')) nameFilter = a.slice(5).toLowerCase(); + else if (a.toLowerCase().startsWith('desc:')) descFilter = a.slice(5).toLowerCase(); + else filterArgs.push(a); + }); + const remainingFilter = filterArgs.length > 0 ? filterArgs.join(' ').toLowerCase() : null; + nameFilter = nameFilter || remainingFilter; + descFilter = descFilter || remainingFilter; + + // Get examples targeting this script + const scriptExamples = Object.values(examples).filter(ex => ex.target === scriptName); + if (scriptExamples.length === 0) { + reply(msg, scriptName, 'Examples', 'No examples registered.'); + return; + } + + const hasSearch = pluginFilter || nameFilter || descFilter; + const matches = (body, filter) => filter ? (body || '').toLowerCase().indexOf(filter) !== -1 : true; + const isExact = (body, filter) => filter ? (body || '').toLowerCase() === filter : false; + + // Tier matching + const tier1 = [], tier2 = [], tier3 = []; + const tier1Sources = new Set(); + + if (pluginFilter) { + scriptExamples.forEach(ex => { + if (ex.source.toLowerCase() === pluginFilter) { + tier1.push(ex); + tier1Sources.add(ex.source); + } + }); + } + + scriptExamples.forEach(ex => { + if (tier1Sources.has(ex.source)) return; + if (pluginFilter && ex.source.toLowerCase().indexOf(pluginFilter) === -1) return; + if (!hasSearch) { tier3.push(ex); return; } + if (!matches(ex.name, nameFilter) && !matches(ex.description, descFilter)) return; + if (isExact(ex.name, nameFilter) || isExact(ex.description, descFilter)) tier2.push(ex); + else tier3.push(ex); + }); + + const allMatches = [...tier1, ...tier2, ...tier3]; + if (allMatches.length === 0) { + reply(msg, scriptName, 'Examples', 'No examples match. Use ' + html.code(reg.command + ' ' + reg.aliases.examples) + ' to see all.'); + return; + } + + // Group by source + const groups = []; + const groupMap = {}; + allMatches.forEach(ex => { + if (!groupMap[ex.source]) { + groupMap[ex.source] = []; + groups.push({ source: ex.source, items: groupMap[ex.source] }); + } + groupMap[ex.source].push(ex); + }); + const tier2Sources = new Set(tier2.map(ex => ex.source)); + groups.sort((a, b) => { + const ta = tier1Sources.has(a.source) ? 0 : tier2Sources.has(a.source) ? 1 : 2; + const tb = tier1Sources.has(b.source) ? 0 : tier2Sources.has(b.source) ? 1 : 2; + if (ta !== tb) return ta - tb; + return a.source.localeCompare(b.source); + }); + + let out = 'Filter: ' + html.code(reg.command + ' ' + reg.aliases.examples + ' ') + ', ' + html.code('script:') + html.paragraph(''); + groups.forEach(g => { + out += html.bold(html.escape(g.source) + ':') + html.br(); + g.items.forEach(ex => { + out += html.indent(2) + 'โ€ข ' + renderExampleEntry(ex, reg, nameFilter, descFilter) + html.br(); + }); + }); + reply(msg, scriptName, 'Examples', out); + }; + + /** + * Render a single example entry with name, description, and action buttons. + */ + const renderExampleEntry = (ex, reg, nameHighlight, descHighlight) => { + const highlightMatch = (text, filter) => { + if (!filter) return html.escape(text); + const lower = text.toLowerCase(); + let result = '', lastIdx = 0, idx = lower.indexOf(filter); + if (idx === -1) return html.escape(text); + while (idx !== -1) { + result += html.escape(text.slice(lastIdx, idx)); + result += html.bold(html.escape(text.slice(idx, idx + filter.length))); + lastIdx = idx + filter.length; + idx = lower.indexOf(filter, lastIdx); + } + result += html.escape(text.slice(lastIdx)); + return result; + }; + + const hasHandler = reg.exampleHandler || ex.handout; + const handoutName = hasHandler ? getHandoutName(reg.tag, ex.source, ex.name, null) : null; + const exists = handoutName ? findObjs({ type: 'handout', name: handoutName })[0] : null; + var entry = html.underline(highlightMatch(ex.name, nameHighlight || '')); + if (ex.description) entry += ' โ€” ' + highlightMatch(ex.description, descHighlight || ''); + entry += ' '; + if (ex.handout || reg.exampleHandler) { + if (exists) { + entry += html.button('๐Ÿ”„ Regen', reg.command + ' ' + reg.aliases.generate + ' ' + ex.name) + ' '; + if (ex.guide && ex.guide.length > 0) { + entry += html.button('๐Ÿงญ Guide', reg.command + ' ' + reg.aliases.guide + ' ' + ex.name) + ' '; + } + entry += html.handoutLink('[Open]', exists.get('id')); + } else { + entry += html.button('+ Generate', reg.command + ' ' + reg.aliases.generate + ' ' + ex.name); + } + } else if (ex.guide && ex.guide.length > 0) { + entry += html.button('๐Ÿงญ Guide', reg.command + ' ' + reg.aliases.guide + ' ' + ex.name); + } + return entry; + }; + + // ========================================================================= + // Example Generation + // ========================================================================= + + const getHandoutName = (defaultTag, source, exampleName, handlerResult) => { + const tag = (handlerResult && handlerResult.tag) || defaultTag; + return (tag ? '[' + tag + '] ' : '') + source + '/example-' + exampleName; + }; + + const defaultExampleHandler = (example, msg) => { + const h = example.handout || {}; + return { + notes: h.notes || '', + gmnotes: h.gmnotes || '', + avatar: h.avatar || '', + inplayerjournals: h.inplayerjournals || '', + archived: h.archived !== undefined ? h.archived : true, + }; + }; + + const generateExample = (msg, scriptName, reg, args) => { + const exName = args[0]; + const key = scriptName + '/' + exName; + const ex = examples[key]; + if (!ex) { + replyError(msg, scriptName, 'No example named "' + (exName || '') + '". Use ' + html.code(reg.command + ' ' + reg.aliases.examples) + ' to see all.'); + return; + } + + const handoutName = ex.handout ? getHandoutName(reg.tag, ex.source, ex.name, null) : null; + + // Build effective guide steps โ€” inject _generate step if not explicitly placed + const hasGuide = ex.guide && ex.guide.length > 0; + const hasExplicitGenerate = hasGuide && ex.guide.some(s => s === HANDOUT_STEP); + + if (hasGuide && !hasExplicitGenerate && ex.handout) { + // Auto-inject: function handout โ†’ append, object handout โ†’ prepend + if (typeof ex.handout === 'function') { + ex._effectiveGuide = ex.guide.concat([HANDOUT_STEP]); + } else { + ex._effectiveGuide = [HANDOUT_STEP].concat(ex.guide); + } + } else if (!hasGuide && ex.handout) { + // No guide at all โ€” just a handout step + ex._effectiveGuide = [HANDOUT_STEP]; + } else { + ex._effectiveGuide = ex.guide || []; + } + + startGuide(msg, scriptName, reg, ex, handoutName); + }; + + // ========================================================================= + // Guide Engine + // ========================================================================= + + const startGuideByName = (msg, scriptName, reg, args) => { + const exName = args[0]; + const key = scriptName + '/' + exName; + const ex = examples[key]; + if (!ex) { + replyError(msg, scriptName, 'No example named "' + (exName || '') + '".'); + return; + } + if (!ex.guide || ex.guide.length === 0) { + replyError(msg, scriptName, 'Example "' + exName + '" has no setup guide.'); + return; + } + const handoutName = getHandoutName(reg.tag, ex.source, ex.name, null); + startGuide(msg, scriptName, reg, ex, handoutName); + }; + + const startGuide = (msg, scriptName, reg, example, handoutName) => { + const guideId = 'guide-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8); + + // Process steps โ€” use _effectiveGuide if set by generateExample, otherwise guide + const steps = (example._effectiveGuide || example.guide || []).slice(); + + activeGuides[guideId] = { + steps: steps, + currentStep: 0, + selections: {}, + params: {}, + _lastQueryValues: {}, + _queryErrors: {}, + _hue: Math.floor(Math.random() * 360), + msg: msg, + handoutName: handoutName, + source: scriptName, + example: example, + reg: reg, + }; + + enterStep(guideId); + }; + + const performDeferredGeneration = (g) => { + const reg = registrations[g.source]; + if (!reg) return; + const ex = g.example; + const ctx = { selections: g.selections, params: g.params, msg: g.msg, handoutName: g.handoutName, player: getObj('player', g.msg.playerid) }; + const handoutData = typeof ex.handout === 'function' ? ex.handout(ctx) : ex.handout; + if (!handoutData) { + log(SCRIPT_NAME + ': handout step in "' + ex.name + '" but no handout data provided.'); + return; + } + const handler = reg.exampleHandler || defaultExampleHandler; + const result = handler(Object.assign({}, ex, { handout: handoutData }), g.msg); + const handoutName = getHandoutName(reg.tag, ex.source, ex.name, result); + g.handoutName = handoutName; + var handout = findObjs({ type: 'handout', name: handoutName })[0]; + if (!handout) { + handout = createObj('handout', { name: handoutName }); + } + if (result.notes) setHandoutNotes(handout, result.notes); + if (result.gmnotes) handout.set('gmnotes', result.gmnotes); + if (result.avatar) handout.set('avatar', result.avatar); + if (result.inplayerjournals !== undefined) handout.set('inplayerjournals', result.inplayerjournals); + if (result.archived !== undefined) handout.set('archived', result.archived); + }; + + const enterStep = (guideId) => { + const g = activeGuides[guideId]; + if (!g) return; + clearAnnotations(); + + if (g.currentStep >= g.steps.length) { + // All steps complete โ€” call onComplete + completeGuide(guideId); + return; + } + + const step = g.steps[g.currentStep]; + + // Conditional step โ€” check 'when' + if (typeof step.when === 'function') { + const ctx = { selections: g.selections, params: g.params, msg: g.msg, player: getObj('player', g.msg.playerid) }; + if (!step.when(ctx)) { + g.currentStep++; + enterStep(guideId); + return; + } + } + + // Handout step โ€” create/update the handout at this point + if (step === HANDOUT_STEP) { + performDeferredGeneration(g); + g.currentStep++; + enterStep(guideId); + return; + } + + // Auto steps advance immediately + if (step.auto) { + if (typeof step.action === 'function') { + const ctx = { selections: g.selections, params: g.params, msg: g.msg, handoutName: g.handoutName, player: getObj('player', g.msg.playerid) }; + step.action(ctx); + } + g.currentStep++; + enterStep(guideId); + return; + } + + // Interactive step โ€” show prompt + const interactiveSteps = g.steps.filter(s => !s.auto && s !== HANDOUT_STEP); + const interactiveIdx = interactiveSteps.indexOf(step) + 1; + const interactiveTotal = interactiveSteps.length; + const hasPriorInteractive = g.steps.slice(0, g.currentStep).some(s => !s.auto && s !== HANDOUT_STEP); + + // Compute hue-rotated background color for this step + const hueIncrement = Math.max(60, 360 / (interactiveTotal || 1)) + 15; + const stepHue = (g._hue + (interactiveIdx - 1) * hueIncrement) % 360; + // Convert HSL(hue, 30%, 20%) to hex for Roll20 compatibility + const h = stepHue / 360, s = 0.3, l = 0.2; + const hue2rgb = (p, q, t) => { if (t < 0) t += 1; if (t > 1) t -= 1; if (t < 1/6) return p + (q - p) * 6 * t; if (t < 1/2) return q; if (t < 2/3) return p + (q - p) * (2/3 - t) * 6; return p; }; + const q = l < 0.5 ? l * (1 + s) : l + s - l * s, p = 2 * l - q; + const r = Math.round(hue2rgb(p, q, h + 1/3) * 255), gr = Math.round(hue2rgb(p, q, h) * 255), b = Math.round(hue2rgb(p, q, h - 1/3) * 255); + const bgColor = '#' + ((1 << 24) + (r << 16) + (gr << 8) + b).toString(16).slice(1); + + // Call onEnter if present, pass advance callback + if (typeof step.onEnter === 'function') { + const advance = (error) => { + if (typeof error === 'string') { replyError(g.msg, g.source, error); return; } + handleGuideContinue(g.msg, guideId, []); + }; + const ctx = { selections: g.selections, params: g.params, msg: g.msg, handoutName: g.handoutName, advance: advance, player: getObj('player', g.msg.playerid) }; + step.onEnter(ctx, advance); + } + + const ctx = { selections: g.selections, params: g.params, msg: g.msg, handoutName: g.handoutName, player: getObj('player', g.msg.playerid) }; + const promptText = resolve(step.prompt, ctx); + + let prompt = html.div( + html.bold(html.escape(g.handoutName || g.example.name)) + ' โ€” Setup (step ' + interactiveIdx + '/' + interactiveTotal + ')' + html.paragraph('') + + html.render(promptText) + html.paragraph('') + + (resolve(step.select, ctx) ? (() => { + const stepSelect = resolve(step.select, ctx); + const stepMin = resolve(step.min, ctx); + const stepMax = resolve(step.max, ctx); + const plural = !stepMax || stepMax > 1; + const label = stepSelect + (plural ? 's' : ''); + const parts = []; + if (stepMin) parts.push('min: ' + stepMin); + if (stepMax) parts.push('max: ' + stepMax); + return parts.length > 0 ? html.italic('Select ' + parts.join(', ') + ' ' + label) + html.paragraph('') : ''; + })() : '') + + (resolve(step.query, ctx) ? (() => { + const stepQuery = resolve(step.query, ctx); + const queries = Array.isArray(stepQuery) ? stepQuery : [stepQuery]; + const lastVals = g._lastQueryValues || {}; + const errors = g._queryErrors || {}; + let qOut = ''; + queries.forEach(q => { + if (errors[q.name]) qOut += html.span('โš  ' + html.escape(errors[q.name]), { color: '#c33' }) + ' '; + qOut += html.bold(html.escape(q.name)); + if (q.options) qOut += ' [dropdown]'; + else if (lastVals[q.name] !== undefined) qOut += ' [' + html.escape(lastVals[q.name]) + ']'; + else if (q.default !== undefined && q.default !== null) qOut += ' [' + html.escape(String(q.default)) + ']'; + if (q.description) qOut += ' โ€” ' + html.italic(html.escape(q.description)); + qOut += html.br(); + }); + qOut += html.br(); + const escQ = (v) => String(v).replace(/\|/g, '|').replace(/,/g, ','); + const queryParts = queries.map(q => { + var def = lastVals[q.name] !== undefined ? lastVals[q.name] : (q.default !== undefined && q.default !== null ? q.default : ''); + if (q.options) { + var opts = q.options.map(o => typeof o === 'string' ? escQ(o) + ',' + escQ(o) : escQ(o.label) + ',' + escQ(o.value)).join('|'); + return '--' + q.name + ' `?{' + q.name + '|' + opts + '}`'; + } + if (q.type === Boolean) return '--' + q.name + ' `?{' + q.name + '|true|false}`'; + if (def !== '') return '--' + q.name + ' `?{' + q.name + '|' + escQ(def) + '}`'; + return '--' + q.name + ' `?{' + q.name + '}`'; + }).join(' '); + qOut += html.button('โœ… Continue', g.reg.command + ' ' + g.reg.aliases.guideContinue + ' ' + guideId + ' ' + queryParts); + return qOut; + })() : html.button('โœ… Continue', g.reg.command + ' ' + g.reg.aliases.guideContinue + ' ' + guideId)) + + (hasPriorInteractive ? ' ' + html.button('โฌ… Back', g.reg.command + ' ' + g.reg.aliases.guideBack + ' ' + guideId) : '') + + ' ' + html.button('โœ– Cancel', g.reg.command + ' ' + g.reg.aliases.guideCancel + ' ' + guideId) + + (step.offerExamples ? (() => { + var offered = ''; + offered += html.paragraph('') + html.bold('What\'s Next?') + html.br(); + step.offerExamples.forEach(function(name) { + var ex = Object.values(examples).find(function(e) { return e.name === name && e.target === g.source; }); + if (!ex) return; + offered += 'โ€ข ' + renderExampleEntry(ex, g.reg) + html.br(); + }); + return offered; + })() : ''), + { background: bgColor, color: '#fff', padding: '8px', borderRadius: '4px', fontSize: '12px' } + ); + + reply(g.msg, g.source, 'Guide', prompt); + }; + + const handleGuideContinue = (msg, guideId, queryArgs) => { + const g = activeGuides[guideId]; + if (!g) { replyError(msg, SCRIPT_NAME, 'No active guide with that ID.'); return; } + + const step = g.steps[g.currentStep]; + const selected = (msg.selected || []).map(s => getObj(s._type, s._id)).filter(Boolean); + const ctx = { selections: g.selections, params: g.params, msg: g.msg, handoutName: g.handoutName, player: getObj('player', g.msg.playerid) }; + + // Handle select steps + var stepSelect = resolve(step.select, ctx); + if (stepSelect) { + const selectType = stepSelect; + const isSubtype = selectType === 'token' || selectType === 'card'; + const matchType = isSubtype ? 'graphic' : selectType; + const filtered = selected.filter(obj => { + if (obj.get('_type') !== matchType) return false; + if (isSubtype && obj.get('_subtype') !== selectType) return false; + return true; + }); + var stepMin = resolve(step.min, ctx); + var stepMax = resolve(step.max, ctx); + const plural = !stepMax || stepMax > 1; + const label = selectType + (plural ? 's' : ''); + if (filtered.length === 0) { + replyError(msg, g.source, 'Select at least one ' + selectType + ', then click Continue.'); + return; + } + if (stepMin && filtered.length < stepMin) { + replyError(msg, g.source, 'Select at least ' + stepMin + ' ' + label + ', then click Continue.'); + return; + } + if (stepMax && filtered.length > stepMax) { + replyError(msg, g.source, 'Select at most ' + stepMax + ' ' + label + ', then click Continue.'); + return; + } + g.selections[step.as] = plural ? filtered : filtered[0]; + } + + // Handle query steps (parse --key value from pre-parsed args, coerce types) + var stepQuery = resolve(step.query, ctx); + if (stepQuery) { + const queries = Array.isArray(stepQuery) ? stepQuery : [stepQuery]; + if (!g._lastQueryValues) g._lastQueryValues = {}; + var queryErrors = {}; + var coercedValues = {}; + + queries.forEach(q => { + // Find --name in queryArgs, value is the next arg + var raw = undefined; + var idx = (queryArgs || []).indexOf('--' + q.name); + if (idx !== -1 && idx + 1 < queryArgs.length) raw = queryArgs[idx + 1]; + + if (raw === undefined) { + if (q.default !== undefined && q.default !== null) { + coercedValues[q.name] = q.default; + g._lastQueryValues[q.name] = String(q.default); + } else { + queryErrors[q.name] = 'Required'; + } + return; + } + + g._lastQueryValues[q.name] = raw; + + // Normalize type to a coercion function + var coerce = q.type || String; + if (coerce === Boolean) coerce = (v) => { if (v !== 'true' && v !== 'false') throw 'Must be true or false'; return v === 'true'; }; + if (coerce === Number) coerce = (v) => { var n = Number(v); if (isNaN(n)) throw 'Invalid number'; return n; }; + + try { + coercedValues[q.name] = coerce(raw); + } catch (e) { + queryErrors[q.name] = typeof e === 'string' ? e : (e.message || 'Invalid value'); + } + }); + + if (Object.keys(queryErrors).length > 0) { + g._queryErrors = queryErrors; + enterStep(guideId); + return; + } + + // All passed โ€” store coerced values in params, clear errors + Object.assign(g.params, coercedValues); + g._queryErrors = {}; + } + + // Role assignment (Choreograph compatibility) + if (step.role) { + if (!g.selections._roles) g.selections._roles = {}; + g.selections._roles[step.role] = selected; + } + + // onContinue callback โ€” if it returns a string or html.raw(), treat as validation error + if (typeof step.onContinue === 'function') { + const ctx = { selections: g.selections, params: g.params, selected: selected, msg: msg, player: getObj('player', msg.playerid) }; + const err = step.onContinue(ctx); + if (typeof err === 'string') { + replyError(msg, g.source, err); + return; + } + if (err && err.__raw) { + replyError(msg, g.source, err); + return; + } + } + + g.currentStep++; + enterStep(guideId); + }; + + const handleGuideBack = (msg, guideId) => { + const g = activeGuides[guideId]; + if (!g || g.currentStep <= 0) return; + + // Call onExit on the step we're leaving + const leavingStep = g.steps[g.currentStep]; + if (leavingStep && typeof leavingStep.onExit === 'function') { + const ctx = { selections: g.selections, params: g.params, msg: msg, handoutName: g.handoutName, player: getObj('player', msg.playerid) }; + leavingStep.onExit(ctx); + } + + // Undo current step + const currentStep = g.steps[g.currentStep]; + if (currentStep && currentStep.as) delete g.selections[currentStep.as]; + if (currentStep && currentStep.role && g.selections._roles) delete g.selections._roles[currentStep.role]; + + // Step backward + g.currentStep--; + const step = g.steps[g.currentStep]; + + // onBack callback + if (typeof step.onBack === 'function') { + const ctx = { selections: g.selections, params: g.params, msg: msg, player: getObj('player', msg.playerid) }; + step.onBack(ctx); + } + + // Undo this step's data + if (step.as) delete g.selections[step.as]; + if (step.role && g.selections._roles) delete g.selections._roles[step.role]; + + // Skip past auto steps when backing up + if (step.auto && g.currentStep > 0) { + handleGuideBack(msg, guideId); + return; + } + + enterStep(guideId); + }; + + const handleGuideCancel = (msg, guideId) => { + const g = activeGuides[guideId]; + if (!g) return; + + // onBack for all steps + for (let i = g.currentStep; i >= 0; i--) { + const step = g.steps[i]; + if (typeof step.onBack === 'function') { + const ctx = { selections: g.selections, params: g.params, msg: msg, player: getObj('player', msg.playerid) }; + step.onBack(ctx); + } + } + + delete activeGuides[guideId]; + reply(msg, g.source, 'Guide', 'Setup cancelled.'); + }; + + const completeGuide = (guideId) => { + const g = activeGuides[guideId]; + if (!g) return; + + const handout = g.handoutName ? findObjs({ type: 'handout', name: g.handoutName })[0] : null; + + const ctx = { + selections: g.selections, + params: g.params, + msg: g.msg, + handoutName: g.handoutName, + handout: handout || null, + example: g.example, + }; + + // Call script-level onComplete if registered + const reg = registrations[g.source]; + if (reg && typeof reg.onComplete === 'function') { + reg.onComplete(ctx); + } + + // Call example-level onComplete if present + if (typeof g.example.onComplete === 'function') { + g.example.onComplete(ctx); + } + + let outMsg = 'Setup complete for ' + html.bold(html.escape(g.handoutName || g.example.name)) + '.'; + if (handout) outMsg += ' [Open]'; + reply(g.msg, g.source, 'Guide', outMsg); + + delete activeGuides[guideId]; + }; + + // ========================================================================= + // Ping Command + // ========================================================================= + + /** + * Build a !scriptkit ping command string. + * @param {string|object} target - An object ID string, or { pageid, x, y } + * @param {object} [opts] - Options: color, moveAll, visibleTo (string of comma-delimited IDs or array) + * @returns {string} The chat command string + */ + const pingCommand = (target, opts) => { + opts = opts || {}; + var cmd; + if (typeof target === 'string') { + cmd = '!scriptkit ping ' + target; + } else { + cmd = '!scriptkit ping ' + target.pageid + ' ' + target.x + ' ' + target.y; + } + if (opts.color) cmd += ' --color ' + opts.color; + if (opts.moveAll) cmd += ' --moveAll'; + if (opts.visibleTo) { + // Normalize: array โ†’ comma-joined, strip all spaces + var vt = Array.isArray(opts.visibleTo) ? opts.visibleTo.join(',') : String(opts.visibleTo); + cmd += ' --visibleTo ' + vt.replace(/\s+/g, ''); + } + return cmd; + }; + + /** + * Handle !scriptkit ping [--color X] [--moveAll] [--visibleTo X,Y,Z] + */ + const doPing = (msg, content) => { + var parts = content.split(/\s+/); + var positional = []; + var color = null; + var moveAll = false; + var visibleTo = null; + + for (var i = 0; i < parts.length; i++) { + if (parts[i] === '--color' && i + 1 < parts.length) { + color = parts[++i]; + } else if (parts[i] === '--moveAll') { + moveAll = true; + } else if (parts[i] === '--visibleTo' && i + 1 < parts.length) { + // Comma-delimited, no spaces (stripped by pingCommand) + visibleTo = parts[++i].split(',').filter(Boolean); + } else { + positional.push(parts[i]); + } + } + + var pageId, x, y; + + if (positional.length === 1) { + // Object ID mode โ€” try graphic, then pin, then pathv2 + var objId = positional[0]; + var obj = getObj('graphic', objId) || getObj('pin', objId) || getObj('pathv2', objId); + if (!obj) { + sendChat('ScriptKit', '/w "' + msg.who.replace(/\s+\(GM\)$/, '') + '" Ping: object not found: ' + objId); + return; + } + pageId = obj.get('_pageid') || obj.get('pageid'); + x = obj.get('left') !== undefined ? obj.get('left') : obj.get('x'); + y = obj.get('top') !== undefined ? obj.get('top') : obj.get('y'); + } else if (positional.length === 3) { + // Coordinate mode: pageId x y + pageId = positional[0]; + x = parseFloat(positional[1]); + y = parseFloat(positional[2]); + } else { + sendChat('ScriptKit', '/w "' + msg.who.replace(/\s+\(GM\)$/, '') + '" Usage: !scriptkit ping <objId> | !scriptkit ping <pageId> <x> <y>'); + return; + } + + var player = getObj('player', msg.playerid); + guidePing(pageId, x, y, { + player: player, + playerId: msg.playerid, + color: color, + moveAll: moveAll, + visibleTo: visibleTo, + }); + }; + + // ========================================================================= + // Public API + // ========================================================================= + + // ========================================================================= + // Usage (unknown command handler) + // ========================================================================= + + /** + * Keyboard-weighted Levenshtein distance. + * Adjacent key substitutions cost less than distant ones. + */ + const levenshtein = (() => { + // QWERTY keyboard layout โ€” adjacency map + const keyPos = {}; + const rows = [['qwertyuiop', 0], ['asdfghjkl', 0.3], ['zxcvbnm', 0.9]]; + rows.forEach(([row, off], r) => { + for (let c = 0; c < row.length; c++) keyPos[row[c]] = { r, c: c + off }; + }); + const keyDist = (a, b) => { + if (a === b) return 0; + const pa = keyPos[a], pb = keyPos[b]; + if (!pa || !pb) return 2; + const dr = Math.abs(pa.r - pb.r), dc = Math.abs(pa.c - pb.c); + if (dr <= 1 && dc <= 1.3) return 0.5; // neighbor + if (dr <= 1 && dc <= 2) return 1; // nearby + return 2; // far + }; + return (a, b) => { + const m = a.length, n = b.length; + const dp = Array.from({ length: m + 1 }, () => Array(n + 1).fill(0)); + for (let i = 0; i <= m; i++) dp[i][0] = i; + for (let j = 0; j <= n; j++) dp[0][j] = j; + for (let i = 1; i <= m; i++) { + for (let j = 1; j <= n; j++) { + if (a[i-1] === b[j-1]) { + dp[i][j] = dp[i-1][j-1]; + } else { + var subCost = dp[i-1][j-1] + keyDist(a[i-1], b[j-1]); + var delCost = dp[i-1][j] + 1; + var insCost = dp[i][j-1] + 1; + dp[i][j] = Math.min(subCost, delCost, insCost); + } + } + } + return dp[m][n]; + }; + })(); + + /** + * Handle unknown commands โ€” suggest corrections, filtered help, or full help. + */ + const handleUsage = (msg, commandOrName, reason) => { + // Auto-detect registration from msg.content command prefix + const cmdPrefix = (msg.content || '').split(/\s+/)[0]; + var scriptName = null; + var reg = null; + Object.keys(registrations).forEach(name => { + if (registrations[name].command === cmdPrefix) { scriptName = name; reg = registrations[name]; } + }); + if (!reg) return; + + // commandOrName is the specific command word (optional) + var command = commandOrName; + + const helpData = reg.help; + + // If a specific command is provided, show its usage directly + if (command) { + var out = ''; + if (reason) out += html.bold('โš  ' + html.escape(reason)) + html.br() + html.br(); + var cmdEntry = null; + if (helpData && helpData.commands) { + var flatCmds = []; + var flatten = (items) => { items.forEach(c => { if (c.group) flatten(c.commands || []); else flatCmds.push(c); }); }; + flatten(helpData.commands); + cmdEntry = flatCmds.find(c => !c.deleted && c.syntax && c.syntax.split(' ')[0].toLowerCase() === command.toLowerCase()); + } + if (cmdEntry) { + out += html.code(reg.command + ' ' + cmdEntry.syntax) + html.br(); + out += html.escape(resolve(cmdEntry.description) || '') + html.br(); + var usageItems = resolve(cmdEntry.items); + if (usageItems && usageItems.length > 0) { + out += html.br(); + usageItems.forEach(item => { + if (resolve(item.deleted)) return; + out += 'โ€ข ' + html.code(resolve(item.name)) + ' โ€” ' + html.escape(resolve(item.description) || '') + html.br(); + }); + } + } else { + out += html.code(reg.command + ' ' + command) + html.br(); + } + reply(msg, scriptName, 'Usage', out); + return; + } + + const content = msg.content.slice(reg.command.length).trim(); + const cmdWord = content.split(/\s+/)[0].toLowerCase(); + if (!cmdWord) { showHelp(msg, scriptName, reg, []); return; } + + if (!helpData) { reply(msg, scriptName, 'Error', 'Unknown command: ' + html.escape(cmdWord)); return; } + + // Collect all known command first-words + var knownCmds = []; + var flatCmds = []; + var flatten = (items) => { items.forEach(c => { if (c.group) flatten(c.commands || []); else flatCmds.push(c); }); }; + if (helpData.commands) flatten(helpData.commands); + flatCmds.forEach(c => { + if (!c.deleted && c.syntax) knownCmds.push(c.syntax.split(' ')[0].toLowerCase()); + }); + // Also include active aliases + Object.values(reg.aliases).forEach(a => { + if (!a) return; + if (Array.isArray(a)) a.forEach(v => knownCmds.push(v.toLowerCase())); + else knownCmds.push(a.toLowerCase()); + }); + knownCmds = [...new Set(knownCmds)]; + + // Also collect topic keys/titles for topic suggestions + var topicNames = []; + if (helpData.topics) { + Object.entries(helpData.topics).forEach(([k, t]) => { + if (!t || t.deleted) return; + topicNames.push({ key: k, title: t.title || k }); + }); + } + + // 1. Fuzzy match against commands + // Threshold scales with input length: short inputs need closer matches + var maxDist = cmdWord.length <= 2 ? 1 : cmdWord.length <= 4 ? 1.5 : 2.5; + var suggestions = knownCmds + .map(c => { + var dist = levenshtein(cmdWord, c); + // Prefix match: if input is a prefix of the command, always suggest + if (c.startsWith(cmdWord)) dist = 0; + else if (cmdWord.startsWith(c)) dist -= c.length * 0.2; + return { cmd: c, dist: dist }; + }) + .filter(s => s.dist <= maxDist) + .sort((a, b) => a.dist - b.dist) + .slice(0, 3); + + // Also check topics (prefix/substring only, no fuzzy) + var topicSuggestions = topicNames + .filter(t => { + var k = t.key.toLowerCase(), title = t.title.toLowerCase(); + return k.startsWith(cmdWord) || title.startsWith(cmdWord) || k.indexOf(cmdWord) !== -1 || title.indexOf(cmdWord) !== -1; + }) + .slice(0, 2); + + if (suggestions.length > 0 || topicSuggestions.length > 0) { + let out = 'Unknown command: ' + html.code(cmdWord) + html.br() + html.br(); + var manAlias = Array.isArray(reg.aliases.man) ? reg.aliases.man[0] : reg.aliases.man; + if (suggestions.length > 0) { + out += 'Did you mean:' + html.br(); + suggestions.forEach(s => { + var cmdEntry = flatCmds.find(c => !c.deleted && c.syntax && c.syntax.split(' ')[0].toLowerCase() === s.cmd); + if (cmdEntry) { + out += 'โ€ข ' + html.code(reg.command + ' ' + cmdEntry.syntax) + ' โ€” ' + html.escape(cmdEntry.description) + html.br(); + } else { + out += 'โ€ข ' + html.code(reg.command + ' ' + s.cmd) + html.br(); + } + }); + } + if (topicSuggestions.length > 0 && manAlias) { + out += (suggestions.length > 0 ? html.br() + 'Or browse a topic:' : 'Did you mean this topic?') + html.br(); + topicSuggestions.forEach(t => { + out += 'โ€ข ' + html.button(t.title, reg.command + ' ' + manAlias + ' ' + t.key) + html.br(); + }); + } + reply(msg, scriptName, 'Help', out); + return; + } + + // 2. Filtered help โ€” does the command word match anything in commands/topics? + var searchResults = []; + flatCmds.forEach(c => { + if (!c.deleted && c.syntax && (c.syntax.toLowerCase().indexOf(cmdWord) !== -1 || (c.description || '').toLowerCase().indexOf(cmdWord) !== -1)) { + searchResults.push(html.code(reg.command + ' ' + c.syntax) + ' โ€” ' + html.escape(c.description)); + } + }); + topicNames.forEach(t => { + if (t.key.toLowerCase().indexOf(cmdWord) !== -1 || t.title.toLowerCase().indexOf(cmdWord) !== -1) { + var manAlias = Array.isArray(reg.aliases.man) ? reg.aliases.man[0] : reg.aliases.man; + searchResults.push(html.button(t.title, reg.command + ' ' + (manAlias || 'man') + ' ' + t.key)); + } + }); + + if (searchResults.length > 0) { + let out = 'Unknown command: ' + html.code(cmdWord) + html.br() + html.br(); + out += 'Related:' + html.br(); + out += html.list(searchResults.slice(0, 5)); + reply(msg, scriptName, 'Help', out); + return; + } + + // 3. Full help fallback โ€” show error + button + var helpAlias = Array.isArray(reg.aliases.help) ? reg.aliases.help[0] : reg.aliases.help; + var out = 'Unknown command: ' + html.code(cmdWord) + html.br() + html.br(); + if (helpAlias) { + out += html.button('๐Ÿ“‹ Show All Commands', reg.command + ' ' + helpAlias); + } + reply(msg, scriptName, 'Help', out); + }; + + // ========================================================================= + // Public API (Proxy-based for namespace access) + // ========================================================================= + + const api = { + VERSION: SCRIPT_VERSION, + register, + registerExample, + handleInput, + html, + handout: () => HANDOUT_STEP, + ping: guidePing, + pingCommand, + annotate: guideAnnotate, + clearAnnotations: clearAnnotations, + waitForCommand: (cmd) => ({ + onEnter: (ctx, advance) => { + ctx._waitFired = false; + on('chat:message', (msg) => { + if (ctx._waitFired) return; + if (msg.type === 'api' && msg.content.split(' ').slice(0, cmd.split(' ').length).join(' ') === cmd) { + ctx._waitFired = true; + advance(); + } + }); + }, + onExit: (ctx) => { + ctx._waitFired = true; + }, + }), + getHandoutName, + startGuide: (msg, scriptName, example, handoutName) => { + const reg = registrations[scriptName]; + if (!reg) return; + startGuide(msg, scriptName, reg, example, handoutName); + }, + getExamples: (scriptName) => Object.values(examples).filter(ex => ex.target === scriptName), + getActiveGuides: () => activeGuides, + generateHandoutImmediately: (scriptName, mode) => { + if (!mode) { log(SCRIPT_NAME + ': generateHandoutImmediately requires mode ("usr" or "dev").'); return; } + const reg = registrations[scriptName]; + if (!reg) { log(SCRIPT_NAME + ': generateHandoutImmediately โ€” "' + scriptName + '" is not registered.'); return; } + generateHelpHandout(null, scriptName, reg, mode); + }, + updateHandoutImmediately: (scriptName, mode) => { + if (!mode) { log(SCRIPT_NAME + ': updateHandoutImmediately requires mode ("usr" or "dev").'); return; } + const reg = registrations[scriptName]; + if (!reg) { log(SCRIPT_NAME + ': updateHandoutImmediately โ€” "' + scriptName + '" is not registered.'); return; } + var handoutName = 'Help: ' + scriptName + (mode === 'dev' ? '/Dev' : ''); + if (!findObjs({ type: 'handout', name: handoutName })[0]) return; + generateHelpHandout(null, scriptName, reg, mode); + }, + generateHandout: (scriptName, mode, delay = 2000) => { + if (!mode) { log(SCRIPT_NAME + ': generateHandout requires mode ("usr" or "dev").'); return; } + var key = scriptName + ':' + mode + ':gen'; + if (!api._handoutTimers) api._handoutTimers = {}; + if (api._handoutTimers[key]) clearTimeout(api._handoutTimers[key]); + api._handoutTimers[key] = setTimeout(() => { delete api._handoutTimers[key]; api.generateHandoutImmediately(scriptName, mode); }, delay); + }, + updateHandout: (scriptName, mode, delay = 2000) => { + if (!mode) { log(SCRIPT_NAME + ': updateHandout requires mode ("usr" or "dev").'); return; } + var key = scriptName + ':' + mode + ':upd'; + if (!api._handoutTimers) api._handoutTimers = {}; + if (api._handoutTimers[key]) clearTimeout(api._handoutTimers[key]); + api._handoutTimers[key] = setTimeout(() => { delete api._handoutTimers[key]; api.updateHandoutImmediately(scriptName, mode); }, delay); + }, + getHelpHandout: (scriptName) => { + const reg = registrations[scriptName]; + return (reg && reg._handouts && reg._handouts.usr) || null; + }, + getDevHandout: (scriptName) => { + const reg = registrations[scriptName]; + return (reg && reg._handouts && reg._handouts.dev) || null; + }, + usage: (msg, command, reason) => handleUsage(msg, command, reason), + }; + + // Tutorial.Choreograph.registerExample('Sequence', { ... }) + // Auto-creates namespace proxy for any unrecognized property access + return new Proxy(api, { + get(target, prop) { + if (prop in target) return target[prop]; + if (typeof prop === 'symbol') return undefined; + return { + registerExample: (registeredBy, struct) => registerExample(String(prop), registeredBy, struct), + }; + }, + }); +})(); + + +on('ready', () => { + 'use strict'; + // Clean up any annotations from a previous session/crash + if (state.ScriptKit && state.ScriptKit._annotations && state.ScriptKit._annotations.length > 0) { + state.ScriptKit._annotations.forEach(function(id) { + var obj = getObj('pathv2', id); + if (obj) obj.remove(); + }); + state.ScriptKit._annotations = []; + } + on('chat:message', (msg) => { + if (msg.type === 'api' && msg.content.split(' ')[0] === '!scriptkit') { + ScriptKit.handleInput(msg); + } + }); + ScriptKit.register('ScriptKit', { + version: ScriptKit.VERSION, + command: 'scriptkit', + handout: 'manual', + aliases: new Proxy({}, { get: (_, key) => key === 'genDev' ? undefined : null }), + help: { + description: 'Generic framework library for Roll20 API scripts.', + changelog: [ + { version: '1.3.0', date: '2026-08-16', changes: [ + 'Lazy evaluation: all registration fields (`description`, `items`, `body`, `title`, `details`, `syntax`, `name`, etc.) can now be functions that return the expected value โ€” evaluated on access', + 'Guide step fields (`select`, `query`, `min`, `max`) also support functions, receiving `ctx` as argument', + 'Man search now resolves dynamic `items` arrays, enabling searchable registry dumps', + 'Fix: code/pre blocks no longer have their content processed as markdown (asterisks, links survive)', + 'Fix: null topic entries no longer crash `man` command', + '`html.table`: wrap in overflow-x:auto div for horizontal scrolling', + '`ScriptKit.getHelpHandout(scriptName)` / `getDevHandout(scriptName)` โ€” cached handout lookup', + '`html.handoutLink` accepts optional anchor parameter for deep-linking to sections', + '`man` topics show ๐Ÿ“– link to handout section', + '`help` command: topics replaced with Browse Topics button; added whatsnew/gen-help/gen-dev-docs to auto-injected commands', + '`!scriptkit whatsnew [date]` โ€” consolidated whatsnew across all plugins with date filtering', + 'Per-plugin `whatsnew` also accepts date argument', + 'Version date tracking: changelog dates stored in state for date-based queries', + '`! changes [search]` โ€” full changelog command with text search', + 'Auto-conflict detection: default aliases matching registered command syntax are auto-nulled at registration', + '`ScriptKit.usage(msg, command?, reason?)` โ€” smart unknown-command handler with keyboard-weighted fuzzy matching, prefix detection, and topic suggestions', + '`! motd` / `!scriptkit motd [plugin]` โ€” on-demand motd with no-repeat tracking and debounced startup delivery', + 'Consolidated "What\'s New" card on startup when plugins upgrade โ€” dismissable, shows changes since last seen', + ]}, + { version: '1.2.0', date: '2026-08-03', changes: [ + 'Added `!scriptkit ping` command โ€” ping an object by ID or by coordinates', + 'Added `ScriptKit.pingCommand(target, opts)` โ€” build ping command strings for chat links', + 'Added `html.pingObjBtn(target, opts)` / `html.pingObjImg(target, opts)` โ€” clickable ping buttons/images', + '`visibleTo` now accepts array of player IDs or comma-delimited string', + ]}, + { version: '1.1.0', date: '2026-08-01', changes: [ + 'Prevent double-registration (same script calling register() twice is now a no-op)', + 'Added ScriptKit.generateHandout / updateHandout (debounced, default 2s) for deferred handout regeneration', + 'Added ScriptKit.generateHandoutImmediately / updateHandoutImmediately for synchronous generation', + ]}, + { version: '1.0.0', date: '2026-07-20', changes: [ + 'Initial release', + 'Help/Man system with grouped commands, searchable topics, version badges', + 'Handout generation with Quick Start, What\'s New, examples footer', + 'Interactive guide engine with selection, queries, type coercion, validation', + 'State migrations with semver comparison, auto-upgrade, manual rollback', + 'HTML helpers with markdown formatting', + 'Ready signal coordination', + ]}, + ], + topics: { + textRendering: { + title: 'โš ๏ธ Text Rendering', + description: 'How ScriptKit handles all text output', + handouts: 'dev', + version: '1.0.0', + body: '**All text in ScriptKit is auto-formatted by default.** Any string you provide for descriptions, prompts, bodies, item names, motd, etc. will have HTML escaped, markdown converted (`` `code` `` โ†’ code, `**bold**` โ†’ bold, `*italic*` โ†’ italic), and `\\n` converted to line breaks.\n\nIf you need to pass **raw HTML** (e.g. from `html.bold()`, `html.table()`, or hand-crafted HTML), wrap it in `html.raw(...)`.\n\nWithout `html.raw()`, HTML tags would be escaped and displayed as literal text.', + }, + registration: { + title: 'Registering Your Script', + description: 'How to register with ScriptKit', + handouts: 'dev', + version: '1.0.0', + body: 'Call `ScriptKit.register(name, opts)` to gain help, man, examples, handout generation, and migration commands. The `!` prefix on command is optional โ€” it is auto-prepended.', + items: [ + { name: 'command (required)', description: 'Your script\'s chat command prefix (e.g. "myscript" or "!myscript")', version: '1.0.0' }, + { name: 'version (required)', description: 'Current script version string (e.g. "1.2.0")', version: '1.0.0' }, + { name: 'tag', description: 'Example handout prefix: [Tag] source/example-name. Optional.', version: '1.0.0' }, + { name: 'help', description: 'Object with description, quickStart, changelog, commands, topics', version: '1.0.0' }, + { name: 'aliases', description: 'Override command keywords. Set to null to disable. Proxy-friendly.', version: '1.0.0' }, + { name: 'newSince', description: 'Version threshold for [new] badges. Default: auto-detect from major.minor change.', version: '1.0.0' }, + { name: 'handout', description: 'User help handout mode: "auto" (default) | "update" | "manual"', version: '1.0.0' }, + { name: 'devHandout', description: 'Dev docs handout mode: "auto" | "update" (default) | "manual"', version: '1.0.0' }, + { name: 'state', description: 'Reference to your persistent state object (for migrations)', version: '1.0.0' }, + { name: 'migrations', description: 'Object keyed by version: { up: fn, down: "string" }', version: '1.0.0' }, + { name: 'motd', description: 'Array of tip strings โ€” random one whispered to GM on startup', version: '1.0.0' }, + { name: 'motdHeader', description: 'Custom header for MOTD card (string or function)', version: '1.0.0' }, + { name: 'motdStyle', description: 'CSS style override object for MOTD card', version: '1.0.0' }, + { name: 'exampleHandler', description: 'Custom (example, msg) => handoutFields function for non-default example generation', version: '1.0.0' }, + { name: 'onComplete', description: 'Callback fired when any guide completes: (ctx) => void', version: '1.0.0' }, + { name: 'onMigrationFailure', description: 'Callback on migration error: ({ version, direction, error, currentStoredVersion }) => void', version: '1.0.0' }, + ], + }, + usage: { + title: 'Unknown Command Handling', + description: 'Smart suggestions for unrecognized commands', + handouts: 'dev', + version: '1.3.0', + body: 'In your `handleInput`, delegate to ScriptKit first with `if (ScriptKit.handleInput(msg)) return;`. For unknown commands at the end of your handler, call `ScriptKit.usage(msg)` instead of showing a generic error.\n\n' + + '**Fuzzy matching:** Keyboard-weighted Levenshtein (QWERTY adjacency), prefix detection, topic suggestions, then a "Show All Commands" button fallback.\n\n' + + '**Command-specific usage:** Pass a command name to show its syntax and flags: `ScriptKit.usage(msg, "run", "Missing scene name")`', + items: [ + { name: 'ScriptKit.usage(msg)', description: 'Unknown command โ€” fuzzy match and suggest (auto-detects script from msg)', version: '1.3.0' }, + { name: 'ScriptKit.usage(msg, command)', description: 'Show registered usage for a specific command', version: '1.3.0' }, + { name: 'ScriptKit.usage(msg, command, reason)', description: 'Show usage with an error/reason message above', version: '1.3.0' }, + ], + }, + helpData: { + title: 'Help Data Structure', + description: 'Defining commands, topics, and changelog', + handouts: 'dev', + version: '1.0.0', + body: 'The `help` object defines what appears in chat help, man search, whatsnew, and generated handouts.\n\n**Lazy evaluation:** Any field (description, items, body, title, details, name, syntax, etc.) can be a function instead of a literal value. ScriptKit calls the function on access and uses the return value. This enables dynamic content that reflects the current state of your registries.', + items: [ + { name: 'description', description: 'Short text shown at top of help and handout. Can be a function.', version: '1.0.0' }, + { name: 'quickStart', description: 'Array of strings โ€” rendered as ordered list in handout', version: '1.0.0' }, + { name: 'changelog', description: 'Array of { version, changes[] } โ€” explicit whatsnew entries', version: '1.0.0' }, + { name: 'commands', description: 'Array of { group, commands[] } โ€” grouped command list', version: '1.0.0' }, + { name: 'command.syntax', description: 'Usage string (e.g. "run [--flag]")', version: '1.0.0' }, + { name: 'command.description', description: 'Short description for help list. Can be a function.', version: '1.0.0' }, + { name: 'command.details', description: 'Longer explanation โ€” handout only. Can be a function.', version: '1.0.0' }, + { name: 'command.items', description: 'Sub-items (flags/args) โ€” rendered as bullet list. Can be a function returning array.', version: '1.0.0' }, + { name: 'command.version', description: 'Version when added โ€” used for [new] badge', version: '1.0.0' }, + { name: 'topics', description: 'Object keyed by id โ€” detailed help topics for man/handout', version: '1.0.0' }, + { name: 'topic.title', description: 'Display title. Can be a function.', version: '1.0.0' }, + { name: 'topic.body', description: 'Main content โ€” string or () => string', version: '1.0.0' }, + { name: 'topic.handouts', description: '"usr" (default) | "dev" | ["usr","dev"] | null (man-only)', version: '1.0.0' }, + { name: 'topic.items', description: 'Array of { name, description, version } sub-items. Can be a function returning array.', version: '1.0.0' }, + ], + }, + lazyEval: { + title: 'Lazy Evaluation', + description: 'Using functions for dynamic registration fields', + handouts: 'dev', + version: '1.3.0', + body: 'Any registration field can be a **function** instead of a static value. ScriptKit resolves it on access โ€” calling the function and using the return value.\n\n' + + 'This is useful for scripts with dynamic registries (e.g. listing all registered attributes, functions, or extensions) where the content isn\'t known until runtime.\n\n' + + '**Help/Man/Handout fields** โ€” called with no arguments:\n' + + '`description`, `title`, `body`, `items`, `details`, `name`, `syntax`, `deleted`, `deprecated`\n\n' + + '**Guide step fields** โ€” called with `ctx` ({ selections, params, msg, handoutName, player }):\n' + + '`prompt`, `select`, `query`, `min`, `max`\n\n' + + '**Example:**\n' + + '```items: () => Object.values(myRegistry).map(r => ({\n' + + ' name: r.name,\n' + + ' description: r.description,\n' + + ' version: \'1.0.0\'\n' + + '}))```\n\n' + + 'This makes the items searchable via `man` and always up-to-date in handouts.', + }, + examples: { + title: 'Registering Examples', + description: 'How to add examples for any script', + handouts: 'dev', + version: '1.0.0', + body: '`ScriptKit.TargetScript.registerExample(yourScript, struct)` โ€” registers an example for any ScriptKit-enabled script.\n\nLoad order does not matter โ€” examples queue via Proxy and drain when the target registers. Your script can register examples for itself or for other scripts.', + items: [ + { name: 'name', description: 'Unique example name (required)', version: '1.0.0' }, + { name: 'description', description: 'Shown in examples menu', version: '1.0.0' }, + { name: 'guide', description: 'Array of guide steps โ€” required if no handout', version: '1.0.0' }, + { name: 'handout', description: 'Object or (ctx) => object โ€” required if no guide', version: '1.0.0' }, + { name: 'handout.notes', description: 'Main handout content', version: '1.0.0' }, + { name: 'handout.gmnotes', description: 'GM-only notes', version: '1.0.0' }, + { name: 'handout.archived', description: 'Archive on create (default: true)', version: '1.0.0' }, + { name: 'onComplete', description: '(ctx) => void โ€” called when guide finishes', version: '1.0.0' }, + ], + }, + guides: { + title: 'Guide Steps', + description: 'Interactive wizard step API', + handouts: 'dev', + version: '1.0.0', + body: 'Each step is an object with `prompt` (string or (ctx) => string, supports `` `code` ``, `**bold**`, `*italic*`).\n\n**ctx object:** `selections`, `params`, `msg`, `handoutName`, `selected`\n\n**Lazy evaluation:** `prompt`, `select`, `query`, `min`, and `max` can all be functions receiving `ctx` โ€” use this for steps that adapt based on earlier selections.\n\nSteps render with hue-rotating backgrounds for visual progression. Errors support markdown formatting.', + items: [ + { name: 'prompt', description: 'String or (ctx) => string โ€” step text with markdown support', version: '1.0.0' }, + { name: 'select', description: 'Roll20 _type filter: "token", "card", "pin", "path"', version: '1.0.0' }, + { name: 'as', description: 'Key to store selection result in ctx.selections', version: '1.0.0' }, + { name: 'min / max', description: 'Selection count constraints', version: '1.0.0' }, + { name: 'query', description: 'Object or array: { name, default?, type?, options? }', version: '1.0.0' }, + { name: 'query.type', description: 'Number, Boolean, String (default), or custom (v) => value that throws on error', version: '1.0.0' }, + { name: 'query.options', description: 'Array of { label, value } or strings โ€” renders as Roll20 dropdown', version: '1.0.0' }, + { name: 'onContinue', description: '(ctx) => errorString? โ€” validate before advancing', version: '1.0.0' }, + { name: 'onEnter', description: '(ctx, advance) => void โ€” called on step entry. advance(error?) to continue.', version: '1.0.0' }, + { name: 'onExit', description: '(ctx) => void โ€” cleanup on Back navigation', version: '1.0.0' }, + { name: 'auto: true', description: 'Skip without user interaction (not counted in step total)', version: '1.0.0' }, + { name: 'ScriptKit.handout()', description: 'Sentinel step โ€” generates handout at this point in the guide', version: '1.0.0' }, + { name: '...ScriptKit.waitForCommand(cmd)', description: 'Spread onto step โ€” auto-advances when cmd detected in chat', version: '1.0.0' }, + { name: 'offerExamples', description: 'Array of example names โ€” renders "What\'s Next?" buttons below the step', version: '1.0.0' }, + ], + }, + guideAnnotations: { + title: 'Guide Annotations', + description: 'Temporary visual aids for interactive guides', + handouts: 'dev', + version: '1.0.0', + body: 'Draw temporary shapes on the map during guide steps to highlight elements. Annotations auto-clear on step transition and are persisted in state for crash recovery.', + items: [ + { name: 'ScriptKit.ping(pageId, x, y, opts)', description: 'Ping the map and move camera. opts: { player, color, moveAll, visibleTo, playerId }', version: '1.0.0' }, + { name: 'ScriptKit.pingCommand(target, opts)', description: 'Build a !scriptkit ping command string. target: objId string or { pageid, x, y }. opts: color, moveAll, visibleTo', version: '1.2.0' }, + { name: 'ScriptKit.annotate(pageId, shape, x, y, opts)', description: 'Draw a temporary pathv2. Returns the object. Shapes: circle, arrow, line, rect', version: '1.0.0' }, + { name: 'ScriptKit.clearAnnotations()', description: 'Remove all active annotations manually', version: '1.0.0' }, + { name: 'circle opts', description: 'radius (default 40), color, strokeWidth, fill', version: '1.0.0' }, + { name: 'arrow opts', description: 'fromX, fromY (tail position), chevronDepth, chevronWidth, color, strokeWidth, fill', version: '1.0.0' }, + { name: 'line opts', description: 'fromX, fromY (start position), color, strokeWidth, fill', version: '1.0.0' }, + { name: 'rect opts', description: 'width (default 80), height (default 80), color, strokeWidth, fill', version: '1.0.0' }, + { name: 'ping opts.color', description: 'Temporarily swaps player color before pinging (100ms delay, restores after 200ms)', version: '1.0.0' }, + ], + }, + handouts: { + title: 'Example Handouts', + description: 'Static and dynamic handout generation', + handouts: 'dev', + version: '1.0.0', + body: 'The `handout` field can be a static object (generated before guide) or a function receiving `ctx` (generated after guide collects params).\n\nPlace `ScriptKit.handout()` in guide to control generation timing. Without it: function handouts generate at end, object handouts at start.\n\n**Naming:** `[tag] source/example-name` (no tag prefix if tag is not set).', + }, + htmlHelpers: { + title: 'HTML Helpers', + description: 'Formatting utilities via ScriptKit.html', + handouts: 'dev', + version: '1.0.0', + body: 'Available via `ScriptKit.html`. Use these in topic bodies, prompts, and handout content.', + items: [ + { name: 'escape(str)', description: 'HTML entities + \\n โ†’
    ', version: '1.0.0' }, + { name: 'format(str)', description: 'Escape + `code`, **bold**, *italic*', version: '1.0.0' }, + { name: 'bold / italic / underline / small / sup / code / pre', description: 'Tag wrappers with optional style object', version: '1.0.0' }, + { name: 'button(label, cmd, style?)', description: 'Clickable command link', version: '1.0.0' }, + { name: 'table(headers, rows, style?)', description: 'HTML table', version: '1.0.0' }, + { name: 'list(items)', description: 'Unordered list (<ul>)', version: '1.0.0' }, + { name: 'orderedList(items)', description: 'Ordered list (<ol>)', version: '1.0.0' }, + { name: 'handoutLink(text, id, style?, anchor?)', description: 'Journal link. Optional anchor param deep-links to a heading section.', version: '1.0.0' }, + { name: 'newBadge() / deprecatedBadge()', description: 'Version badge markers', version: '1.0.0' }, + { name: 'style(obj)', description: 'Convert camelCase object to CSS string', version: '1.0.0' }, + { name: 'pingObjBtn(target, opts?)', description: 'Clickable text button that pings an object location. opts: color, moveAll, visibleTo, label, style', version: '1.2.0' }, + { name: 'pingObjImg(target, opts?)', description: 'Clickable token image that pings on click. opts: color, moveAll, visibleTo, imgsrc, width, height, label. Falls back to pingObjBtn', version: '1.2.0' }, + ], + }, + migrations: { + title: 'State Migrations', + description: 'Versioned state upgrades and rollbacks', + handouts: 'dev', + version: '1.0.0', + body: 'Register `state` and `migrations` to enable auto-upgrade and manual rollback.\n\n**up** โ€” function, runs on upgrade. Receives state reference.\n**down** โ€” string, persisted in state.ScriptKit.migrations for rollback after code swap.\n**Working copy** โ€” all migrations run on a JSON clone. Committed only if all succeed.\n**Failure** โ€” throw to abort. State remains untouched.\n\n**Rollback scenario:** Install v1.2.0 (down strings stored) โ†’ downgrade to v1.0.0 โ†’ run `!cmd migrate` โ†’ stored strings eval\'d โ†’ state rolled back.', + items: [ + { name: 'up: (state) => { ... }', description: 'Forward migration function', version: '1.0.0' }, + { name: 'down: "(state) => { ... }"', description: 'Rollback as string โ€” must be self-contained, no closures', version: '1.0.0' }, + { name: '!cmd migrate', description: 'Syncs state to current script version (forward or backward)', version: '1.0.0' }, + ], + }, + motd: { + title: 'MOTD', + description: 'Message of the Day โ€” random tips on startup', + handouts: 'dev', + version: '1.0.0', + body: 'Whispers a random tip from `motd` array to the GM on sandbox restart. Styled as a card with customizable header and frame.', + items: [ + { name: 'motd', description: 'Array of tip strings (supports markdown formatting). Empty = disabled.', version: '1.0.0' }, + { name: 'motdHeader', description: 'String or (version) => string. Default: "๐Ÿ’ก ScriptName vX.Y.Z"', version: '1.0.0' }, + { name: 'motdStyle', description: 'CSS style object merged with default card appearance', version: '1.0.0' }, + ], + }, + readySignal: { + title: 'Ready Signal', + description: 'Load-order coordination between scripts', + handouts: 'dev', + version: '1.0.0', + body: '`ScriptKit.register()` automatically sends `!-ready` to chat. Other scripts listen for this to know when your script is available for direct API calls.\n\nExample registration does NOT require the ready signal โ€” the Proxy queues automatically regardless of load order.', + }, + dynamicHandouts: { + title: 'Dynamic Handout Content', + description: 'Keeping handouts up-to-date with post-init registrations', + handouts: 'dev', + version: '1.1.0', + body: 'If your script has topics with dynamic content (e.g. listing registered extensions), the handout generated at startup may be incomplete โ€” other scripts register after yours.\n\n' + + '**Step 1: Function fields** โ€” Use `body: () => ...` and/or `items: () => [...]` in topics. Chat commands (`man`) always evaluate live, giving users current info. Dynamic `items` are also searchable via `man`.\n\n' + + '**Step 2: Programmatic regeneration** โ€” Call `ScriptKit.generateHandout()` or `ScriptKit.updateHandout()` after extensions finish registering. This re-renders the handout, invoking your function fields with up-to-date data.\n\n' + + '**Pattern:** Debounce a timer in your extension registration functions. After the last registration, the timer fires and regenerates the handout once.', + items: [ + { name: 'ScriptKit.generateHandout(scriptName, mode, delay?)', description: 'Schedule handout generation (debounced, default 2000ms). Creates handout if missing. Repeated calls reset the timer.', version: '1.1.0' }, + { name: 'ScriptKit.updateHandout(scriptName, mode, delay?)', description: 'Schedule handout regeneration only if it already exists (debounced, default 2000ms). No-op if handout is missing.', version: '1.1.0' }, + { name: 'ScriptKit.generateHandoutImmediately(scriptName, mode)', description: 'Generate handout synchronously without debounce. Creates if missing.', version: '1.1.0' }, + { name: 'ScriptKit.updateHandoutImmediately(scriptName, mode)', description: 'Regenerate handout synchronously only if it exists.', version: '1.1.0' }, + { name: 'ScriptKit.getHelpHandout(scriptName)', description: 'Returns the cached user help handout object for a script (or null). No findObjs call.', version: '1.3.0' }, + { name: 'ScriptKit.getDevHandout(scriptName)', description: 'Returns the cached dev docs handout object for a script (or null). No findObjs call.', version: '1.3.0' }, + { name: 'body: () => string', description: 'Topic body as a function โ€” evaluated at render time (chat man) and at handout generation. Always reflects current state.', version: '1.0.0' }, + ], + }, + scriptKit: null, + } + } + }); +}); \ No newline at end of file diff --git a/ScriptKit/README.md b/ScriptKit/README.md index 1545fe138..41850f229 100644 --- a/ScriptKit/README.md +++ b/ScriptKit/README.md @@ -89,13 +89,37 @@ const handleInput = (msg) => { if (msg.type !== 'api') return; if (msg.content.split(' ')[0] !== '!myscript') return; - // ScriptKit handles help, man, examples, whatsnew, gen-help, gen-dev-docs + // ScriptKit handles help, man, examples, whatsnew, changes, gen-help, gen-dev-docs if (typeof ScriptKit !== 'undefined' && ScriptKit.handleInput(msg)) return; - // ... your command handling + // ... your command handling ... + + // Unknown command fallback โ€” fuzzy suggestions + if (typeof ScriptKit !== 'undefined') ScriptKit.usage(msg); }; ``` +### Unknown Command Handling + +`ScriptKit.usage(msg, command?, reason?)` provides smart feedback for unrecognized or misused commands. The script is auto-detected from the message's command prefix. + +```js +// Unknown command โ€” fuzzy match, suggest corrections, topic matches, or "Show All Commands" button +ScriptKit.usage(msg); + +// Command-specific usage (e.g. missing required argument) +ScriptKit.usage(msg, 'run', 'Missing scene name'); +``` + +**Fuzzy matching features:** +- Keyboard-weighted Levenshtein distance (QWERTY adjacency โ€” nearby keys cost less) +- Prefix detection โ€” input that is a prefix of a command always suggests it +- Length-scaled thresholds โ€” short inputs require closer matches +- Topic suggestions via prefix/substring (no fuzzy for topics) +- Progressive fallback: suggestions โ†’ filtered results โ†’ "Show All Commands" button + +**Auto-conflict detection:** If a default alias (e.g. `changes`) matches one of your registered command syntaxes, ScriptKit auto-nulls that alias at registration time โ€” your command takes priority without needing manual alias overrides. + ### Ready Signal Pattern When `ScriptKit.register()` is called, it automatically sends `!-ready` to chat. Other scripts can listen for this to know when your script is available (e.g. for direct API calls). Note: registering examples does NOT require waiting for the ready signal โ€” ScriptKit's Proxy queues them automatically regardless of load order. @@ -122,6 +146,8 @@ on('chat:message',(msg) => { The `help` object defines what appears in `!cmd help`, `!cmd man`, `!cmd whatsnew`, and the generated handouts. +**Lazy evaluation:** Any field in the help structure (`description`, `title`, `body`, `items`, `details`, `name`, `syntax`, `deleted`, `deprecated`) can be a **function** instead of a literal value. ScriptKit calls the function on access and uses the return value. This enables dynamic content that always reflects the current state of your registries โ€” e.g. `items: () => myRegistry.map(r => ({ name: r.name, description: r.desc }))`. Dynamic items are searchable via `man`. + ```js help: { description: 'Short description shown at the top of help.', @@ -233,10 +259,10 @@ Guides are interactive multi-step wizards that walk users through setup. Each st | Field | Description | |-------|-------------| | `prompt` | String or `(ctx) => string`. Supports markdown: `` `code` ``, `**bold**`, `*italic*` | -| `select` | Roll20 `_type` to filter selection: `'token'`, `'card'`, `'pin'`, `'path'` | +| `select` | Roll20 `_type` to filter selection: `'token'`, `'card'`, `'pin'`, `'path'`. Can be `(ctx) => string`. | | `as` | Key to store selection in `ctx.selections` | -| `min` / `max` | Selection count constraints | -| `query` | Object or array: `{ name, default?, type?, options? }` | +| `min` / `max` | Selection count constraints. Can be `(ctx) => number`. | +| `query` | Object or array: `{ name, default?, type?, options? }`. Can be `(ctx) => object/array`. | | `onContinue` | `(ctx) => errorString?` โ€” validate before advancing. Return a string to block with an error. | | `onEnter` | `(ctx, advance) => void` โ€” called when step becomes active. `advance(error?)` to programmatically continue. | | `onExit` | `(ctx) => void` โ€” called when leaving via Back. Clean up listeners, revert changes. | @@ -516,6 +542,27 @@ const onExtensionRegistered = () => { ## Changelog +### v1.3.0 +- **Lazy evaluation** โ€” all registration fields (`description`, `items`, `body`, `title`, `details`, `name`, `syntax`, etc.) can now optionally be functions that return the expected value. ScriptKit resolves them on access. Guide step fields (`select`, `query`, `min`, `max`) receive `ctx` when resolved. +- Man search now resolves dynamic `items` arrays, enabling searchable registry dumps from function-based items +- Fix: code/pre blocks (`backtick-delimited`) no longer have their content processed as markdown โ€” asterisks and `[link](url)` syntax inside code spans are preserved +- Fix: null topic entries no longer crash `man` command +- `html.table`: wraps output in `overflow-x:auto` div for horizontal scrolling; headers use `white-space:nowrap` +- `ScriptKit.getHelpHandout(scriptName)` / `ScriptKit.getDevHandout(scriptName)` โ€” cached handout object lookup +- `html.handoutLink(text, id, style, anchor)` โ€” optional 4th param for deep-linking to handout sections +- `man` topics display a ๐Ÿ“– link to the corresponding handout section +- `help` command: inline topics list replaced with "Browse Topics" button; auto-injected commands now include `whatsnew`, `gen-help`, `gen-dev-docs` +- `!scriptkit whatsnew [date]` โ€” consolidated whatsnew across all registered plugins, with optional date filtering +- Per-plugin `whatsnew` also accepts a date argument (ISO or human-readable) +- Version date tracking: changelog `date` fields stored in state; current version auto-stamped on first registration +- `! changes [search]` โ€” full changelog command with text search +- Auto-conflict detection: default aliases matching registered command syntax are auto-nulled at registration +- `ScriptKit.usage(msg)` โ€” smart unknown-command handler: keyboard-weighted fuzzy suggestions, prefix matching, topic suggestions, progressive fallback +- `ScriptKit.usage(msg, command, reason)` โ€” command-specific usage display with optional error reason +- `! motd` / `!scriptkit motd [plugin]` โ€” on-demand random tip with no-repeat tracking, debounced single-tip startup delivery, derived button styling +- Consolidated "What's New" card on startup โ€” shows changes since last seen when plugins upgrade, with dismissable "โœ“ Dismiss" button (`!scriptkit dismiss-whatsnew`) +- Updated registration log format (`ศ˜๊€ โš™โš™`) + ### v1.2.0 - Added `!scriptkit ping` chat command โ€” pings an object's location by ID or by pageId/x/y coordinates - Added `ScriptKit.pingCommand(target, opts)` โ€” builds a ping command string for embedding in chat links diff --git a/ScriptKit/ScriptKit.js b/ScriptKit/ScriptKit.js index 17a377d9b..718fa1923 100644 --- a/ScriptKit/ScriptKit.js +++ b/ScriptKit/ScriptKit.js @@ -1,6 +1,6 @@ // ============================================================================= -// ScriptKit v1.2.0 -// Last Updated: 2026-08-03 +// ScriptKit v1.3.0 +// Last Updated: 2026-08-16 // Author: Kenan Millet // // Description: @@ -17,9 +17,49 @@ var ScriptKit = ScriptKit || (() => { 'use strict'; const SCRIPT_NAME = 'ScriptKit'; - const SCRIPT_VERSION = '1.2.0'; + const SCRIPT_VERSION = '1.3.0'; const HANDOUT_STEP = Object.freeze({ auto: true }); + // In-memory MOTD tracking (resets on sandbox restart) + const motdSeen = {}; // { scriptName: Set of shown indices } + + const pickMotd = (scriptName, motdArr) => { + if (!motdArr || motdArr.length === 0) return null; + if (!motdSeen[scriptName]) motdSeen[scriptName] = new Set(); + var seen = motdSeen[scriptName]; + if (seen.size >= motdArr.length) seen.clear(); + var unseen = motdArr.map((_, i) => i).filter(i => !seen.has(i)); + var idx = unseen[Math.floor(Math.random() * unseen.length)]; + seen.add(idx); + return motdArr[idx]; + }; + + const renderMotdCard = (scriptName, tip, reg, btnCmd) => { + var frameStyle = Object.assign( + { background: '#1a1a2e', color: '#eee', padding: '8px 12px', borderRadius: '4px', borderLeft: '3px solid #4fc3f7', fontSize: '12px', marginTop: '4px' }, + (reg && reg.motdStyle) || {} + ); + var version = reg ? reg.version : ''; + var header = reg && reg.motdHeader + ? (typeof reg.motdHeader === 'function' ? reg.motdHeader(version) : reg.motdHeader) + : '๐Ÿ’ก **' + scriptName + ' v' + version + '**'; + var anotherCmd = btnCmd || (reg ? reg.command + ' motd' : '!scriptkit motd'); + // Derive button style: explicit motdButtonStyle > derive from frame > default + var btnStyle = null; + if (reg && reg.motdButtonStyle) { + btnStyle = reg.motdButtonStyle; + } else { + var borderMatch = frameStyle.borderLeft?.match(/#[0-9a-fA-F]{3,8}|rgb[a]?\([^)]+\)/); + btnStyle = {}; + if (borderMatch) btnStyle.border = '1px solid ' + borderMatch[0]; + } + var showAnotherBtn = html.br() + html.br() +'
    ' + html.button('ยป Next Tip ยป', anotherCmd, btnStyle) + '
    '; + return html.div( + '
    ' + html.render(header) + '
    ' + html.br() + html.render(tip) + showAnotherBtn, + frameStyle + ); + }; + // ========================================================================= // Registry // ========================================================================= @@ -178,7 +218,7 @@ var ScriptKit = ScriptKit || (() => { br: () => '
    ', indent: (n) => ' '.repeat(n || 2), link: (text, url, style) => '' + text + '', - handoutLink: (text, id, style) => '' + text + '', + handoutLink: (text, id, style, anchor) => '' + text + '', version: (ver, style) => html.sup('[v' + ver + ']', style), newBadge: (style) => html.sup('[new]', style ? style : { color: '#c33', fontWeight: 'bold' }), deprecatedBadge: (ver, style) => html.sup('[deprecated' + (ver ? ' v' + ver : '') + ']', style ? style : { color: '#323' }), @@ -203,10 +243,23 @@ var ScriptKit = ScriptKit || (() => { } } var out = html.escape(escaped); - out = out.replace(/```([^`]+)```/g, '
    $1
    '); - out = out.replace(/`([^`]+)`/g, '$1'); + // Extract code/pre spans into placeholders (protects content from markdown) + var codeSlots = []; + out = out.replace(/```([^`]+)```/g, function(_, content) { + codeSlots.push('
    ' + content.replace(/\*/g, '*').replace(/\[/g, '[') + '
    '); + return '\uFFFECODE' + (codeSlots.length - 1) + '\uFFFE'; + }); + out = out.replace(/`([^`]+)`/g, function(_, content) { + codeSlots.push('' + content.replace(/\*/g, '*').replace(/\[/g, '[') + ''); + return '\uFFFECODE' + (codeSlots.length - 1) + '\uFFFE'; + }); + // Apply markdown (placeholders don't contain * so won't interfere) out = out.replace(/\*\*([^*]+)\*\*/g, '$1'); out = out.replace(/\*([^*]+)\*/g, '$1'); + // Restore code/pre spans + out = out.replace(/\uFFFECODE(\d+)\uFFFE/g, function(_, idx) { + return codeSlots[parseInt(idx, 10)]; + }); return out; }, button: (label, command, style) => { @@ -282,6 +335,13 @@ var ScriptKit = ScriptKit || (() => { setTimeout(() => handout.set('notes', notes), 200); }; + /** + * Resolve a registration field value. If the value is a function, call it + * (optionally passing ctx) and return the result. Otherwise return as-is. + * This enables lazy evaluation of any registration field. + */ + const resolve = (value, ctx) => typeof value === 'function' ? value(ctx) : value; + // ========================================================================= // Registration API // ========================================================================= @@ -315,6 +375,8 @@ var ScriptKit = ScriptKit || (() => { help: ['help', '--help'], man: 'man', whatsnew: 'whatsnew', + changes: 'changes', + motd: 'motd', genHelp: 'gen-help', genDev: 'gen-dev-docs', examples: 'examples', @@ -330,6 +392,20 @@ var ScriptKit = ScriptKit || (() => { var val = userAliases[k]; if (val !== undefined) defaults[k] = val; }); + // Auto-null aliases that conflict with registered commands + if (opts.help && opts.help.commands) { + var flatCmds = []; + var flatten = (items) => { items.forEach(c => { if (c.group) flatten(c.commands || []); else flatCmds.push(c); }); }; + flatten(opts.help.commands); + var cmdFirstWords = new Set(flatCmds.filter(c => !c.deleted && c.syntax).map(c => c.syntax.split(' ')[0].toLowerCase())); + Object.keys(defaults).forEach(k => { + if (!defaults[k] || userAliases[k] !== undefined) return; // skip nulled or explicitly set + var aliases = Array.isArray(defaults[k]) ? defaults[k] : [defaults[k]]; + if (aliases.some(a => cmdFirstWords.has(a.toLowerCase()))) { + defaults[k] = null; + } + }); + } return defaults; })(), exampleHandler: opts.exampleHandler || null, @@ -342,6 +418,11 @@ var ScriptKit = ScriptKit || (() => { newSince: opts.newSince || null, handoutMode: opts.handout || 'auto', // 'auto' | 'update' | 'manual' devHandoutMode: opts.devHandout || 'update', // 'auto' | 'update' | 'manual' + motd: opts.motd || null, + motdHeader: opts.motdHeader !== undefined ? opts.motdHeader : null, + motdStyle: opts.motdStyle || null, + motdButtonStyle: opts.motdButtonStyle || null, + _handouts: { usr: null, dev: null }, }; // Drain pending queue for this script @@ -365,57 +446,112 @@ var ScriptKit = ScriptKit || (() => { state[SCRIPT_NAME].previousVersions[scriptName] = currentStored; } state[SCRIPT_NAME].versions[scriptName] = opts.version; + + // Track version dates + if (!state[SCRIPT_NAME].versionDates[scriptName]) state[SCRIPT_NAME].versionDates[scriptName] = {}; + var vDates = state[SCRIPT_NAME].versionDates[scriptName]; + // Store dates from changelog entries (canonical source for historical versions) + if (opts.help && opts.help.changelog) { + opts.help.changelog.forEach(function(entry) { + if (entry.date && !vDates[entry.version]) { + var parsed = new Date(entry.date).getTime(); + if (!isNaN(parsed)) vDates[entry.version] = parsed; + } + }); + } + // Auto-stamp current version if no date stored yet + if (!vDates[opts.version]) { + vDates[opts.version] = opts.versionDate ? new Date(opts.versionDate).getTime() : Date.now(); + } } + var usrHandout = findObjs({ type: 'handout', name: 'Help: ' + scriptName })[0]; + if (usrHandout) registrations[scriptName]._handouts.usr = usrHandout; // Auto-generate user help handout based on handoutMode ('auto' | 'update' | 'manual') if (opts.help && opts.version && registrations[scriptName].handoutMode !== 'manual') { ensureState(); var storedVer = state[SCRIPT_NAME].versions[scriptName] || '0.0.0'; - var existing = findObjs({ type: 'handout', name: 'Help: ' + scriptName })[0]; var versionChanged = compareSemver(storedVer, opts.version) !== 0; var shouldGenerate = registrations[scriptName].handoutMode === 'auto' - ? (versionChanged || !existing) - : (versionChanged && existing); // 'update' mode: only if exists AND version changed + ? (versionChanged || !usrHandout) + : (versionChanged && usrHandout); // 'update' mode: only if exists AND version changed if (shouldGenerate) { setTimeout(() => generateHelpHandout(null, scriptName, registrations[scriptName], 'usr'), 500); } } + var devHandout = findObjs({ type: 'handout', name: 'Help: ' + scriptName + '/Dev' })[0]; + if (devHandout) registrations[scriptName]._handouts.dev = devHandout; // Auto-generate dev handout based on devHandoutMode ('auto' | 'update' | 'manual') if (opts.help && opts.version && registrations[scriptName].devHandoutMode !== 'manual') { ensureState(); var storedVerDev = state[SCRIPT_NAME].versions[scriptName] || '0.0.0'; - var existingDev = findObjs({ type: 'handout', name: 'Help: ' + scriptName + '/Dev' })[0]; var versionChangedDev = compareSemver(storedVerDev, opts.version) !== 0; var shouldGenerateDev = registrations[scriptName].devHandoutMode === 'auto' - ? (versionChangedDev || !existingDev) - : (versionChangedDev && existingDev); + ? (versionChangedDev || !devHandout) + : (versionChangedDev && devHandout); if (shouldGenerateDev) { setTimeout(() => generateHelpHandout(null, scriptName, registrations[scriptName], 'dev'), 600); } } + if (opts.version) { + var _d = new Date(vDates[opts.version]); + var _ds = _d.getFullYear() + '/' + String(_d.getMonth() + 1).padStart(2, '0') + '/' + String(_d.getDate()).padStart(2, '0'); + log(`ศ˜๊€ โš™โš™ ${scriptName} version ${opts.version} (${_ds}) ready.`); + } + // Send ready signal sendChat('', registrations[scriptName].command + '-ready', null, { noarchive: true }); - // MOTD โ€” whisper a random tip to GM on registration - if (opts.motd && Array.isArray(opts.motd) && opts.motd.length > 0) { - setTimeout(() => { - var tip = opts.motd[Math.floor(Math.random() * opts.motd.length)]; - var frameStyle = Object.assign( - { background: '#1a1a2e', color: '#eee', padding: '8px 12px', borderRadius: '4px', borderLeft: '3px solid #4fc3f7', fontSize: '12px', marginTop: '4px' }, - opts.motdStyle || {} - ); - var header = opts.motdHeader !== undefined - ? (typeof opts.motdHeader === 'function' ? opts.motdHeader(opts.version) : opts.motdHeader) - : '๐Ÿ’ก **' + scriptName + ' v' + (opts.version || '') + '**'; - var card = html.div( - html.render(header) + html.br() + html.render(tip), - frameStyle - ); - sendChat(scriptName, '/w gm ' + card, null, { noarchive: true }); - }, 1500); - } + // Startup debounce: 10s after last registration, show What's New card + random motd + if (register._startupTimer) clearTimeout(register._startupTimer); + register._startupTimer = setTimeout(() => { + delete register._startupTimer; + ensureState(); + var lastSeen = state[SCRIPT_NAME].lastSeenVersions; + + // What's New card โ€” show if any plugin upgraded since last seen + var upgradedPlugins = []; + Object.keys(registrations).forEach(name => { + var r = registrations[name]; + if (name === SCRIPT_NAME) return; // skip ScriptKit itself + if (!r.help || !r.help.changelog || !r.version) return; + var seen = lastSeen[name]; + if (!seen) { lastSeen[name] = r.version; return; } // first install โ€” store, no card + if (compareSemver(seen, r.version) >= 0) return; // already seen this version + // Collect changelog entries between lastSeen and current + var entries = r.help.changelog.filter(e => compareSemver(e.version, seen) > 0 && compareSemver(e.version, r.version) <= 0); + if (entries.length > 0) upgradedPlugins.push({ name, reg: r, entries }); + }); + + if (upgradedPlugins.length > 0) { + var out = html.bold("What's New") + html.paragraph(''); + upgradedPlugins.forEach(p => { + out += html.bold(html.escape(p.name) + ' v' + p.reg.version) + html.br(); + var changes = []; + p.entries.forEach(e => { if (Array.isArray(e.changes)) changes.push(...e.changes); }); + if (changes.length > 0) out += html.list(changes.slice(0, 5).map(c => html.escape(c))); + if (changes.length > 5) out += html.small('...and ' + (changes.length - 5) + ' more') + html.br(); + if (p.reg.aliases && p.reg.aliases.whatsnew) out += html.button('See Details', p.reg.command + ' ' + p.reg.aliases.whatsnew) + html.br(); + out += html.br(); + }); + out += html.br() + '
    ' + html.button('โœ“ Dismiss', '!scriptkit dismiss-whatsnew') + '
    '; + sendChat(SCRIPT_NAME, '/w gm ' + out, null, { noarchive: true }); + } + + // MOTD โ€” one random tip from global pool + var pluginsWithMotd = Object.keys(registrations).filter(n => registrations[n].motd && registrations[n].motd.length > 0); + if (pluginsWithMotd.length > 0) { + var randomPlugin = pluginsWithMotd[Math.floor(Math.random() * pluginsWithMotd.length)]; + var rReg = registrations[randomPlugin]; + var tip = pickMotd(randomPlugin, rReg.motd); + if (tip) { + var card = renderMotdCard(randomPlugin, tip, rReg, '!scriptkit motd'); + sendChat(SCRIPT_NAME, '/w gm ' + card, null, { noarchive: true }); + } + } + }, 10000); return true; }; @@ -429,6 +565,8 @@ var ScriptKit = ScriptKit || (() => { if (!state[SCRIPT_NAME].versions) state[SCRIPT_NAME].versions = {}; if (!state[SCRIPT_NAME].previousVersions) state[SCRIPT_NAME].previousVersions = {}; if (!state[SCRIPT_NAME].migrations) state[SCRIPT_NAME].migrations = {}; + if (!state[SCRIPT_NAME].versionDates) state[SCRIPT_NAME].versionDates = {}; + if (!state[SCRIPT_NAME].lastSeenVersions) state[SCRIPT_NAME].lastSeenVersions = {}; }; /** @@ -712,6 +850,78 @@ var ScriptKit = ScriptKit || (() => { doPing(msg, args.join(' ')); return true; } + if (scriptName === SCRIPT_NAME && cmd === 'dismiss-whatsnew') { + ensureState(); + Object.keys(registrations).forEach(name => { + if (registrations[name].version) state[SCRIPT_NAME].lastSeenVersions[name] = registrations[name].version; + }); + reply(msg, SCRIPT_NAME, 'What\'s New', 'Dismissed. You won\'t see these changes again until the next update.'); + return true; + } + if (scriptName === SCRIPT_NAME && cmd === 'motd') { + // Show a random motd from a specific plugin or the global pool + var targetPlugin = args.length > 0 ? args.join(' ') : null; + if (targetPlugin) { + // Specific plugin + var targetReg = registrations[targetPlugin]; + if (!targetReg || !targetReg.motd || targetReg.motd.length === 0) { + reply(msg, SCRIPT_NAME, 'Tip', 'No tips registered for ' + html.escape(targetPlugin) + '.'); + return true; + } + var tip = pickMotd(targetPlugin, targetReg.motd); + if (tip) reply(msg, SCRIPT_NAME, 'Tip', renderMotdCard(targetPlugin, tip, targetReg, '!scriptkit motd ' + targetPlugin)); + } else { + // Global pool โ€” pick a random plugin that has motds, then pick from it + var pluginsWithMotd = Object.keys(registrations).filter(n => registrations[n].motd && registrations[n].motd.length > 0); + if (pluginsWithMotd.length === 0) { + reply(msg, SCRIPT_NAME, 'Tip', 'No tips registered across any plugins.'); + return true; + } + var randomPlugin = pluginsWithMotd[Math.floor(Math.random() * pluginsWithMotd.length)]; + var rReg = registrations[randomPlugin]; + var tip = pickMotd(randomPlugin, rReg.motd); + if (tip) reply(msg, SCRIPT_NAME, 'Tip', renderMotdCard(randomPlugin, tip, rReg, '!scriptkit motd')); + } + return true; + } + if (scriptName === SCRIPT_NAME && cmd === 'whatsnew') { + // Parse optional date argument + var sinceDate = null; + if (args.length > 0) { + sinceDate = parseDate(args.join(' ')); + if (!sinceDate) { + reply(msg, SCRIPT_NAME, 'Error', 'Could not parse date: "' + html.escape(args.join(' ')) + '". Use ISO (2026-08-01) or human-readable (August 12, 2026).'); + return true; + } + } + + // Consolidated whatsnew across all registered plugins + let out = html.bold('What\'s New โ€” All Plugins'); + if (sinceDate) out += ' ' + html.small('since ' + new Date(sinceDate).toLocaleDateString()); + out += html.paragraph(''); + let hasContent = false; + var dateArg = sinceDate ? ' ' + args.join(' ') : ''; + Object.keys(registrations).forEach(name => { + const r = registrations[name]; + if (name === SCRIPT_NAME || !r.help || !r.version) return; + var relevant = getRelevantChangelog(name, r, sinceDate); + if (relevant.length === 0) return; + hasContent = true; + out += html.bold(html.escape(name) + ' v' + r.version) + html.br(); + var changes = []; + relevant.forEach(e => { if (Array.isArray(e.changes)) changes.push(...e.changes); }); + if (changes.length > 0) out += html.list(changes.slice(0, 5).map(c => html.escape(c))); + if (changes.length > 5) out += html.small('...and ' + (changes.length - 5) + ' more') + html.br(); + out += html.button('See Details', r.command + ' ' + (r.aliases.whatsnew || 'whatsnew') + dateArg) + html.paragraph(''); + }); + if (!hasContent) { + out += sinceDate + ? 'No changes since ' + new Date(sinceDate).toLocaleDateString() + '.' + : 'Nothing new across any registered plugins.'; + } + reply(msg, SCRIPT_NAME, 'What\'s New', out); + return true; + } // Alias matcher โ€” supports string or array of strings const matchAlias = (alias) => { @@ -730,7 +940,24 @@ var ScriptKit = ScriptKit || (() => { return true; } if (matchAlias(reg.aliases.whatsnew)) { - showWhatsNew(msg, scriptName, reg); + showWhatsNew(msg, scriptName, reg, args); + return true; + } + if (matchAlias(reg.aliases.changes)) { + showChanges(msg, scriptName, reg, args); + return true; + } + if (matchAlias(reg.aliases.motd)) { + var motdArr = reg.motd; + if (motdArr && motdArr.length > 0) { + var tip = pickMotd(scriptName, motdArr); + if (tip) { + var card = renderMotdCard(scriptName, tip, reg); + reply(msg, scriptName, 'Tip', card); + } + } else { + reply(msg, scriptName, 'Tip', 'No tips registered for ' + scriptName + '.'); + } return true; } if (matchAlias(reg.aliases.genHelp)) { @@ -791,10 +1018,11 @@ var ScriptKit = ScriptKit || (() => { const version = reg.version; let out = ''; - if (helpData.description) { + var helpDesc = resolve(helpData.description); + if (helpDesc) { out += html.bold(html.escape(scriptName)); if (version) out += ' ' + html.small(html.bold('v' + version)); - out += html.paragraph(html.small(helpData.description)) + html.line(); + out += html.paragraph(html.small(helpDesc)) + html.line(); } // Commands @@ -806,6 +1034,11 @@ var ScriptKit = ScriptKit || (() => { if (helpAlias) autoCommands.push({ syntax: helpAlias, description: 'Show this help' }); if (manAlias && helpData.topics && Object.keys(helpData.topics).length > 0) autoCommands.push({ syntax: manAlias + ' [topic]', description: 'Detailed help by topic' }); if (reg.aliases.examples) autoCommands.push({ syntax: reg.aliases.examples, description: 'Browse examples' }); + if (reg.aliases.whatsnew) autoCommands.push({ syntax: reg.aliases.whatsnew, description: 'Show what\'s new in this version' }); + if (reg.aliases.changes) autoCommands.push({ syntax: reg.aliases.changes + ' [search]', description: 'Full changelog (filterable)' }); + if (reg.aliases.motd && reg.motd && reg.motd.length > 0) autoCommands.push({ syntax: reg.aliases.motd, description: 'Show a random tip' }); + if (reg.aliases.genHelp) autoCommands.push({ syntax: reg.aliases.genHelp, description: 'Regenerate help handout' }); + if (reg.aliases.genDev) autoCommands.push({ syntax: reg.aliases.genDev, description: 'Generate dev docs handout' }); var renderCommands = function(items, search, version, reg) { var out = ''; @@ -817,12 +1050,13 @@ var ScriptKit = ScriptKit || (() => { } } else { if (c.deleted) return; + var cDesc = resolve(c.description) || ''; if (search && (c.syntax || '').toLowerCase().indexOf(search) === -1 && - (c.description || '').toLowerCase().indexOf(search) === -1) return; + cDesc.toLowerCase().indexOf(search) === -1) return; var vTag = ''; if (isNewVersion(c.version, reg)) vTag = ' ' + html.newBadge(); if (c.deprecated) vTag = ' ' + html.deprecatedBadge(); - out += html.code(reg.command + ' ' + c.syntax) + vTag + ' โ€” ' + html.escape(c.description) + html.br(); + out += html.code(reg.command + ' ' + c.syntax) + vTag + ' โ€” ' + html.escape(cDesc) + html.br(); } }); return out; @@ -839,30 +1073,18 @@ var ScriptKit = ScriptKit || (() => { } } - // Topics summary (if man is available) - if (helpData.topics && Object.keys(helpData.topics).length > 0 && reg.aliases.man) { - var topicKeys = Object.keys(helpData.topics).filter(k => !helpData.topics[k].deleted); - if (search) { - topicKeys = topicKeys.filter(k => { - var t = helpData.topics[k]; - return k.toLowerCase().indexOf(search) !== -1 || - (t.title || '').toLowerCase().indexOf(search) !== -1 || - (t.description || '').toLowerCase().indexOf(search) !== -1; - }); - } - if (topicKeys.length > 0) { - var manCmd = Array.isArray(reg.aliases.man) ? reg.aliases.man[0] : reg.aliases.man; - out += html.line() + html.bold('Topics') + ' ' + html.small('(use ' + html.code(reg.command + ' ' + manCmd + ' ') + ')') + html.br(); - topicKeys.forEach(k => { - var t = helpData.topics[k]; - var vTag = ''; - if (isNewVersion(t.version, reg)) vTag = ' ' + html.newBadge(); - if (t.deprecated) vTag = ' ' + html.deprecatedBadge(); - out += 'โ€ข ' + html.bold(html.escape(t.title || k)) + vTag; - if (t.description) out += ' โ€” ' + html.escape(t.description); - out += html.br(); - }); + // Topics button (if man is available โ€” via alias or user-registered command) + if (helpData.topics && Object.keys(helpData.topics).filter(k => helpData.topics[k] && !helpData.topics[k].deleted).length > 0) { + var manCmd = reg.aliases.man ? (Array.isArray(reg.aliases.man) ? reg.aliases.man[0] : reg.aliases.man) : null; + // If man alias was auto-nulled but a user command handles it, find that command word + if (!manCmd && helpData.commands) { + var flatCmds = []; + var flattenC = (items) => { items.forEach(c => { if (c.group) flattenC(c.commands || []); else flatCmds.push(c); }); }; + flattenC(helpData.commands); + var manEntry = flatCmds.find(c => !c.deleted && c.syntax && c.syntax.split(' ')[0].toLowerCase() === 'man'); + if (manEntry) manCmd = manEntry.syntax.split(' ')[0]; } + if (manCmd) out += html.line() + html.button('๐Ÿ“– Browse Topics', reg.command + ' ' + manCmd); } if (!out) { @@ -890,9 +1112,10 @@ var ScriptKit = ScriptKit || (() => { if (!search) { let out = html.bold(html.escape(scriptName) + ' โ€” Topics:') + html.paragraph(''); Object.entries(topics).forEach(([key, t]) => { - if (t.deleted) return; - out += 'โ€ข ' + html.button(t.title || key, reg.command + ' ' + manCmd + ' ' + key); - if (t.description) out += ' โ€” ' + html.escape(t.description); + if (!t || t.deleted) return; + out += 'โ€ข ' + html.button(resolve(t.title) || key, reg.command + ' ' + manCmd + ' ' + key); + var tDesc = resolve(t.description); + if (tDesc) out += ' โ€” ' + html.escape(tDesc); out += html.br(); }); reply(msg, scriptName, 'Man', out); @@ -919,7 +1142,7 @@ var ScriptKit = ScriptKit || (() => { // Tier 1b: exact title match const titleMatch = Object.entries(topics).find(([k, t]) => - !t.deleted && (t.title || k).toLowerCase() === search + t && !t.deleted && (resolve(t.title) || k).toLowerCase() === search ); if (titleMatch) { renderManTopic(msg, scriptName, reg, titleMatch[0], titleMatch[1]); @@ -929,25 +1152,26 @@ var ScriptKit = ScriptKit || (() => { // Tier 2: search within topic items/body const results = []; Object.entries(topics).forEach(([key, t]) => { - if (t.deleted) return; + if (!t || t.deleted) return; // Check items array if present - if (t.items && Array.isArray(t.items)) { - t.items.forEach(item => { - const nameMatch = (item.name || '').toLowerCase().indexOf(search) !== -1; - const descMatch = (item.description || '').toLowerCase().indexOf(search) !== -1; + var tItems = resolve(t.items); + if (tItems && Array.isArray(tItems)) { + tItems.forEach(item => { + const nameMatch = (resolve(item.name) || '').toLowerCase().indexOf(search) !== -1; + const descMatch = (resolve(item.description) || '').toLowerCase().indexOf(search) !== -1; if (nameMatch || descMatch) { results.push({ topicKey: key, topic: t, item: item, exact: nameMatch }); } }); } // Check body text - var bodyText = typeof t.body === 'function' ? '' : (t.body || ''); + var bodyText = resolve(t.body) || ''; if (bodyText && bodyText.toLowerCase().indexOf(search) !== -1) { results.push({ topicKey: key, topic: t, item: null, exact: false }); } // Check title/description - if ((t.title || '').toLowerCase().indexOf(search) !== -1 || - (t.description || '').toLowerCase().indexOf(search) !== -1) { + if ((resolve(t.title) || '').toLowerCase().indexOf(search) !== -1 || + (resolve(t.description) || '').toLowerCase().indexOf(search) !== -1) { results.push({ topicKey: key, topic: t, item: null, exact: true }); } }); @@ -959,11 +1183,13 @@ var ScriptKit = ScriptKit || (() => { flatCommands.forEach(c => { if (c.deleted || !c.items) return; var cmdKey = c.syntax.split(' ')[0]; - c.items.forEach(item => { - const nameMatch = (item.name || '').toLowerCase().indexOf(search) !== -1; - const descMatch = (item.description || '').toLowerCase().indexOf(search) !== -1; + var cItems = resolve(c.items); + if (!cItems) return; + cItems.forEach(item => { + const nameMatch = (resolve(item.name) || '').toLowerCase().indexOf(search) !== -1; + const descMatch = (resolve(item.description) || '').toLowerCase().indexOf(search) !== -1; if (nameMatch || descMatch) { - results.push({ topicKey: cmdKey, topic: { title: reg.command + ' ' + c.syntax, description: c.description }, item: item, exact: nameMatch, _cmd: c }); + results.push({ topicKey: cmdKey, topic: { title: reg.command + ' ' + c.syntax, description: resolve(c.description) }, item: item, exact: nameMatch, _cmd: c }); } }); }); @@ -985,15 +1211,18 @@ var ScriptKit = ScriptKit || (() => { const shown = new Set(); results.sort((a, b) => (b.exact ? 1 : 0) - (a.exact ? 1 : 0)); results.forEach(r => { - if (shown.has(r.topicKey + '/' + (r.item ? r.item.name : ''))) return; - shown.add(r.topicKey + '/' + (r.item ? r.item.name : '')); + if (shown.has(r.topicKey + '/' + (r.item ? resolve(r.item.name) : ''))) return; + shown.add(r.topicKey + '/' + (r.item ? resolve(r.item.name) : '')); if (r.item) { - out += 'โ€ข ' + html.bold(html.escape(r.item.name)); - if (r.item.description) out += ' โ€” ' + html.escape(r.item.description).slice(0, 80); - out += html.br() + html.indent(2) + html.italic('in ' + html.button(r.topic.title || r.topicKey, reg.command + ' ' + manCmd + ' ' + r.topicKey)) + html.br(); + out += 'โ€ข ' + html.bold(html.escape(resolve(r.item.name))); + var rItemDesc = resolve(r.item.description); + if (rItemDesc) out += ' โ€” ' + html.escape(rItemDesc).slice(0, 80); + var inLabel = r._cmd ? 'Commands: ' + r.topicKey : html.escape(resolve(r.topic.title) || r.topicKey); + out += html.br() + html.indent(2) + html.italic('in ' + html.button(inLabel, reg.command + ' ' + manCmd + ' ' + r.topicKey)) + html.br(); } else { - out += 'โ€ข ' + html.button(r.topic.title || r.topicKey, reg.command + ' ' + manCmd + ' ' + r.topicKey); - if (r.topic.description) out += ' โ€” ' + html.escape(r.topic.description); + out += 'โ€ข ' + html.button(html.escape(resolve(r.topic.title) || r.topicKey), reg.command + ' ' + manCmd + ' ' + r.topicKey); + var rTopicDesc = resolve(r.topic.description); + if (rTopicDesc) out += ' โ€” ' + html.escape(rTopicDesc); out += html.br(); } }); @@ -1004,29 +1233,36 @@ var ScriptKit = ScriptKit || (() => { * Render a full man topic. */ const renderManTopic = (msg, scriptName, reg, key, topic) => { - let out = html.bold(html.escape(topic.title || key)); + let out = html.bold(html.escape(resolve(topic.title) || key)); if (topic.version) out += ' ' + html.italic('(v' + topic.version + ')'); + // Link to handout section if handout exists + var helpHandout = reg._handouts && reg._handouts.usr; + if (helpHandout) out += ' ' + html.handoutLink('๐Ÿ“–', helpHandout.get('id'), null, resolve(topic.title) || key); out += html.br(); - if (topic.description) out += html.escape(topic.description) + html.br(); + var topicDesc = resolve(topic.description); + if (topicDesc) out += html.escape(topicDesc) + html.br(); out += html.br(); // Render body (supports string or function) if (topic.body) { - var body = typeof topic.body === 'function' ? topic.body() : topic.body; + var body = resolve(topic.body); out += html.render(body) + html.paragraph(''); } // Render items (structured entries within a topic) - if (topic.items && Array.isArray(topic.items)) { - topic.items.forEach(item => { - if (item.deleted) return; + var topicItems = resolve(topic.items); + if (topicItems && Array.isArray(topicItems)) { + topicItems.forEach(item => { + if (resolve(item.deleted)) return; var vTag = ''; if (item.version && isNewVersion(item.version, reg)) vTag = ' ' + html.newBadge(); - if (item.deprecated) vTag = ' ' + html.deprecatedBadge(); - out += html.bold(html.escape(item.name)) + vTag; - if (item.syntax) out += ' ' + html.code(item.syntax); + if (resolve(item.deprecated)) vTag = ' ' + html.deprecatedBadge(); + out += html.bold(html.escape(resolve(item.name))) + vTag; + var itemSyntax = resolve(item.syntax); + if (itemSyntax) out += ' ' + html.code(itemSyntax); out += html.br(); - if (item.description) out += html.render(item.description) + html.br(); + var itemDesc = resolve(item.description); + if (itemDesc) out += html.render(itemDesc) + html.br(); out += html.br(); }); } @@ -1041,19 +1277,23 @@ var ScriptKit = ScriptKit || (() => { let out = html.bold(html.code(reg.command + ' ' + cmd.syntax)); if (cmd.version) out += ' ' + html.italic('(v' + cmd.version + ')'); out += html.br(); - if (cmd.description) out += html.escape(cmd.description) + html.br(); - if (cmd.details) out += html.br() + html.render(cmd.details) + html.br(); + var cmdDesc = resolve(cmd.description); + if (cmdDesc) out += html.escape(cmdDesc) + html.br(); + var cmdDetails = resolve(cmd.details); + if (cmdDetails) out += html.br() + html.render(cmdDetails) + html.br(); out += html.br(); - if (cmd.items) { - cmd.items.forEach(item => { - if (item.deleted) return; + var cmdItems = resolve(cmd.items); + if (cmdItems) { + cmdItems.forEach(item => { + if (resolve(item.deleted)) return; var vTag = ''; if (isNewVersion(item.version, reg)) vTag = ' ' + html.newBadge(); - if (item.deprecated) vTag = ' ' + html.deprecatedBadge(); - out += html.bold(html.escape(item.name)) + vTag; + if (resolve(item.deprecated)) vTag = ' ' + html.deprecatedBadge(); + out += html.bold(html.escape(resolve(item.name))) + vTag; out += html.br(); - if (item.description) out += html.render(item.description) + html.br(); + var itemDesc = resolve(item.description); + if (itemDesc) out += html.render(itemDesc) + html.br(); out += html.br(); }); } @@ -1061,77 +1301,157 @@ var ScriptKit = ScriptKit || (() => { reply(msg, scriptName, 'Man', out); }; + /** + * Parse a date string (ISO or human-readable). Returns timestamp or null. + */ + const parseDate = (str) => { + if (!str) return null; + var t = new Date(str).getTime(); + return isNaN(t) ? null : t; + }; + + /** + * Get relevant changelog entries for a plugin, filtered by date or version. + * @param {object} reg - registration object + * @param {number|null} sinceDate - timestamp threshold, or null for version-based + * @returns {Array} matching changelog entries + */ + const getRelevantChangelog = (scriptName, reg, sinceDate) => { + const helpData = reg.help; + if (!helpData || !helpData.changelog) return []; + ensureState(); + var pluginDates = (state[SCRIPT_NAME].versionDates || {})[scriptName] || {}; + if (sinceDate) { + return helpData.changelog.filter(e => { + var d = pluginDates[e.version]; + return d && d >= sinceDate; + }); + } + return helpData.changelog.filter(e => isNewVersion(e.version, reg)); + }; + /** * Show what's new in current version (chat command). */ - const showWhatsNew = (msg, scriptName, reg) => { + const showWhatsNew = (msg, scriptName, reg, args) => { const helpData = reg.help; if (!helpData) { reply(msg, scriptName, 'What\'s New', 'No help data registered.'); return; } + // Parse optional date argument + var sinceDate = null; + if (args && args.length > 0) { + sinceDate = parseDate(args.join(' ')); + if (!sinceDate) { + reply(msg, scriptName, 'Error', 'Could not parse date: "' + html.escape(args.join(' ')) + '".'); + return; + } + } + const version = reg.version; const commands = helpData.commands || []; const topics = helpData.topics || {}; - let out = html.bold(html.escape(scriptName) + ' โ€” What\'s New') + html.paragraph(''); + let out = html.bold(html.escape(scriptName) + ' โ€” What\'s New'); + if (sinceDate) out += ' ' + html.small('since ' + new Date(sinceDate).toLocaleDateString()); + out += html.paragraph(''); - // Explicit changelog entries - if (helpData.changelog && helpData.changelog.length > 0) { - var relevant = helpData.changelog.filter(e => isNewVersion(e.version, reg)); - if (relevant.length > 0) { - relevant.forEach(e => { - out += html.bold('v' + e.version) + html.br(); - if (Array.isArray(e.changes)) { - out += html.list(e.changes.map(c => html.escape(c))); - } else if (e.changes) { - out += html.escape(e.changes) + html.br(); - } - }); - out += html.line(); + // Changelog entries + var relevant = getRelevantChangelog(scriptName, reg, sinceDate); + if (relevant.length > 0) { + relevant.forEach(e => { + out += html.bold('v' + e.version) + html.br(); + if (Array.isArray(e.changes)) { + out += html.list(e.changes.map(c => html.escape(c))); + } else if (e.changes) { + out += html.escape(e.changes) + html.br(); + } + }); + out += html.line(); + } + + // Auto-detected new items (only for version-based mode) + if (!sinceDate) { + var newItems = []; + var flatCommands = []; + var flattenCmds = (items) => { items.forEach(c => { if (c.group) flattenCmds(c.commands || []); else flatCommands.push(c); }); }; + flattenCmds(commands); + flatCommands.forEach(c => { + if (!c.deleted && isNewVersion(c.version, reg)) { + newItems.push(html.code(reg.command + ' ' + c.syntax) + ' โ€” ' + html.escape(resolve(c.description) || '')); + } + }); + Object.entries(topics).forEach(([k, t]) => { + if (!t || t.deleted) return; + if (isNewVersion(t.version, reg)) { + newItems.push(html.bold(resolve(t.title) || k) + (resolve(t.description) ? ' โ€” ' + html.escape(resolve(t.description)) : '')); + } + var tItems = resolve(t.items); + if (tItems) { + tItems.forEach(item => { + if (!resolve(item.deleted) && isNewVersion(item.version, reg) && !isNewVersion(t.version, reg)) { + newItems.push(html.bold(resolve(item.name)) + ' in ' + html.italic(resolve(t.title) || k) + (resolve(item.description) ? ' โ€” ' + resolve(item.description) : '')); + } + }); + } + }); + if (newItems.length > 0) { + out += html.bold('New Features:') + html.br(); + out += html.list(newItems); } } - // Auto-detected new items - var newItems = []; + if (out.indexOf('
  • ') === -1 && out.indexOf('
    ') === -1) { + out += sinceDate + ? 'No changes since ' + new Date(sinceDate).toLocaleDateString() + '.' + : 'Nothing new since last version.'; + } - // New commands - var flatCommands = []; - var flattenCmds = (items) => { items.forEach(c => { if (c.group) flattenCmds(c.commands || []); else flatCommands.push(c); }); }; - flattenCmds(commands); - flatCommands.forEach(c => { - if (!c.deleted && isNewVersion(c.version, reg)) { - newItems.push(html.code(reg.command + ' ' + c.syntax) + ' โ€” ' + html.escape(c.description)); - } - }); + reply(msg, scriptName, 'What\'s New', out); + }; - // New topics - Object.entries(topics).forEach(([k, t]) => { - if (t.deleted) return; - if (isNewVersion(t.version, reg)) { - newItems.push(html.bold(t.title || k) + (t.description ? ' โ€” ' + html.escape(t.description) : '')); - } - // New items within existing topics - if (t.items) { - t.items.forEach(item => { - if (!item.deleted && isNewVersion(item.version, reg) && !isNewVersion(t.version, reg)) { - newItems.push(html.bold(item.name) + ' in ' + html.italic(t.title || k) + (item.description ? ' โ€” ' + item.description : '')); - } - }); + /** + * Show full changelog for a script (chat command). Optional search filter. + */ + const showChanges = (msg, scriptName, reg, args) => { + const helpData = reg.help; + if (!helpData || !helpData.changelog || helpData.changelog.length === 0) { + reply(msg, scriptName, 'Changes', 'No changelog registered.'); + return; + } + + const search = args && args.length > 0 ? args.join(' ').toLowerCase() : null; + const changelog = helpData.changelog; + + let out = html.bold(html.escape(scriptName) + ' โ€” Changelog'); + if (search) out += ' ' + html.small('(filter: "' + html.escape(search) + '")'); + out += html.paragraph(''); + + let hasResults = false; + changelog.forEach(entry => { + const changes = Array.isArray(entry.changes) ? entry.changes : (entry.changes ? [entry.changes] : []); + let matchingChanges; + if (search) { + // Match on version or change text + const versionMatch = entry.version.toLowerCase().indexOf(search) !== -1; + matchingChanges = versionMatch ? changes : changes.filter(c => c.toLowerCase().indexOf(search) !== -1); + if (matchingChanges.length === 0) return; + } else { + matchingChanges = changes; } + hasResults = true; + var dateStr = entry.date ? ' ' + html.small('(' + entry.date + ')') : ''; + out += html.bold('v' + entry.version) + dateStr + html.br(); + out += html.list(matchingChanges.map(c => html.escape(c))); }); - if (newItems.length > 0) { - out += html.bold('New Features:') + html.br(); - out += html.list(newItems); + if (!hasResults) { + out += 'No changes matching "' + html.escape(search) + '".'; } - if (!newItems.length && !(helpData.changelog && helpData.changelog.length)) { - out += 'Nothing new since last version.'; - } - - reply(msg, scriptName, 'What\'s New', out); + reply(msg, scriptName, 'Changes', out); }; // ========================================================================= @@ -1191,13 +1511,15 @@ var ScriptKit = ScriptKit || (() => { var flattenC = (items) => { items.forEach(c => { if (c.group) flattenC(c.commands || []); else flatCmds.push(c); }); }; flattenC(commands); flatCmds.forEach(c => { - if (!c.deleted && isNewVersion(c.version, reg)) whatsNew.push(html.code(reg.command + ' ' + c.syntax) + ' โ€” ' + html.escape(c.description)); - if (c.items) { - c.items.forEach(item => { - if (!item.deleted && isNewVersion(item.version, reg) && !isNewVersion(c.version, reg)) { - if (!whatsNewSeen[item.name]) { - whatsNewSeen[item.name] = true; - whatsNew.push(html.code(item.name) + ' in ' + html.code(reg.command + ' ' + c.syntax) + ' โ€” ' + html.escape(item.description)); + if (!c.deleted && isNewVersion(c.version, reg)) whatsNew.push(html.code(reg.command + ' ' + c.syntax) + ' โ€” ' + html.escape(resolve(c.description) || '')); + var cItems = resolve(c.items); + if (cItems) { + cItems.forEach(item => { + if (!resolve(item.deleted) && isNewVersion(item.version, reg) && !isNewVersion(c.version, reg)) { + var iName = resolve(item.name); + if (!whatsNewSeen[iName]) { + whatsNewSeen[iName] = true; + whatsNew.push(html.code(iName) + ' in ' + html.code(reg.command + ' ' + c.syntax) + ' โ€” ' + html.escape(resolve(item.description) || '')); } } }); @@ -1205,12 +1527,14 @@ var ScriptKit = ScriptKit || (() => { }); filteredTopicKeys.forEach(k => { var t = topics[k]; - if (isNewVersion(t.version, reg)) whatsNew.push(html.bold(t.title || k) + (t.description ? ' โ€” ' + html.escape(t.description) : '')); - if (t.items) { - t.items.forEach(item => { - if (!item.deleted && isNewVersion(item.version, reg) && !whatsNewSeen[item.name]) { - whatsNewSeen[item.name] = true; - whatsNew.push(html.bold(item.name) + (item.description ? ' โ€” ' + item.description : '')); + if (isNewVersion(t.version, reg)) whatsNew.push(html.bold(resolve(t.title) || k) + (resolve(t.description) ? ' โ€” ' + html.escape(resolve(t.description)) : '')); + var tItems = resolve(t.items); + if (tItems) { + tItems.forEach(item => { + var iName = resolve(item.name); + if (!resolve(item.deleted) && isNewVersion(item.version, reg) && !whatsNewSeen[iName]) { + whatsNewSeen[iName] = true; + whatsNew.push(html.bold(iName) + (resolve(item.description) ? ' โ€” ' + resolve(item.description) : '')); } }); } @@ -1224,10 +1548,11 @@ var ScriptKit = ScriptKit || (() => { Object.keys(topics).forEach(k => { var t = topics[k]; if (!t) return; - if (t.deleted) removed.push(html.bold(t.title || k) + ' โ€” removed in v' + t.deleted); - if (t.items) { - t.items.forEach(item => { - if (item.deleted) removed.push(html.bold(item.name) + ' โ€” removed in v' + item.deleted); + if (t.deleted) removed.push(html.bold(resolve(t.title) || k) + ' โ€” removed in v' + t.deleted); + var tItems = resolve(t.items); + if (tItems) { + tItems.forEach(item => { + if (resolve(item.deleted)) removed.push(html.bold(resolve(item.name)) + ' โ€” removed in v' + resolve(item.deleted)); }); } }); @@ -1235,7 +1560,8 @@ var ScriptKit = ScriptKit || (() => { // Render HTML var out = ''; out += '

    ' + html.escape(scriptName) + (version ? ' v' + version : '') + '

    '; - if (helpData.description) out += '

    ' + html.render(helpData.description) + '

    '; + var handoutDesc = resolve(helpData.description); + if (handoutDesc) out += '

    ' + html.render(handoutDesc) + '

    '; // Quick Start / Examples callout (user handout only) var hasExamples = reg.aliases && reg.aliases.examples && Object.values(examples).filter(e => e.target === scriptName).length > 0; @@ -1283,17 +1609,19 @@ var ScriptKit = ScriptKit || (() => { if (cmdIsNew) badge = ' ' + html.newBadge(); if (c.deprecated) badge = ' ' + html.deprecatedBadge(); if (mode === 'dev' && c.version) badge += ' ' + html.version(c.version); - result += '

    ' + html.code(reg.command + ' ' + c.syntax) + ' โ€” ' + html.escape(c.description) + badge + '

    '; - if (c.details) result += '

    ' + html.render(c.details) + '

    '; - if (c.items) { + result += '

    ' + html.code(reg.command + ' ' + c.syntax) + ' โ€” ' + html.escape(resolve(c.description) || '') + badge + '

    '; + var cDetails = resolve(c.details); + if (cDetails) result += '

    ' + html.render(cDetails) + '

    '; + var cItems = resolve(c.items); + if (cItems) { result += '
      '; - c.items.forEach(item => { - if (item.deleted) return; + cItems.forEach(item => { + if (resolve(item.deleted)) return; var iBadge = ''; if (!cmdIsNew && isNewVersion(item.version, reg)) iBadge = ' ' + html.newBadge(); - if (item.deprecated) iBadge = ' ' + html.deprecatedBadge(); + if (resolve(item.deprecated)) iBadge = ' ' + html.deprecatedBadge(); if (mode === 'dev' && item.version) iBadge += ' ' + html.version(item.version); - result += '
    • ' + html.code(item.name) + ' โ€” ' + html.escape(item.description) + iBadge + '
    • '; + result += '
    • ' + html.code(resolve(item.name)) + ' โ€” ' + html.escape(resolve(item.description) || '') + iBadge + '
    • '; }); result += '
    '; } @@ -1313,25 +1641,31 @@ var ScriptKit = ScriptKit || (() => { if (topicIsNew) badge = ' ' + html.newBadge(); if (t.deprecated) badge = ' ' + html.deprecatedBadge(); if (mode === 'dev' && t.version) badge += ' ' + html.version(t.version); - out += '

    ' + html.escape(t.title || k) + badge + '

    '; - if (t.description) out += '

    ' + html.escape(t.description) + '

    '; - if (t.details) out += '

    ' + html.render(t.details) + '

    '; + out += '

    ' + html.escape(resolve(t.title) || k) + badge + '

    '; + var tDesc = resolve(t.description); + if (tDesc) out += '

    ' + html.escape(tDesc) + '

    '; + var tDetails = resolve(t.details); + if (tDetails) out += '

    ' + html.render(tDetails) + '

    '; if (t.body) { - var body = typeof t.body === 'function' ? t.body() : t.body; + var body = resolve(t.body); out += '
    ' + html.render(body) + '
    '; } - if (t.items) { - t.items.forEach(item => { - if (item.deleted) return; + var tItems = resolve(t.items); + if (tItems) { + tItems.forEach(item => { + if (resolve(item.deleted)) return; var iBadge = ''; if (!topicIsNew && isNewVersion(item.version, reg)) iBadge = ' ' + html.newBadge(); - if (item.deprecated) iBadge = ' ' + html.deprecatedBadge(); + if (resolve(item.deprecated)) iBadge = ' ' + html.deprecatedBadge(); if (mode === 'dev' && item.version) iBadge += ' ' + html.version(item.version); - out += '

    ' + html.bold(html.escape(item.name)) + iBadge; - if (item.syntax) out += ' ' + html.code(item.syntax); + out += '

    ' + html.bold(html.escape(resolve(item.name))) + iBadge; + var itemSyntax = resolve(item.syntax); + if (itemSyntax) out += ' ' + html.code(itemSyntax); out += '

    '; - if (item.description) out += '

    ' + html.render(item.description) + '

    '; - if (item.details) out += '

    ' + html.render(item.details) + '

    '; + var itemDesc = resolve(item.description); + if (itemDesc) out += '

    ' + html.render(itemDesc) + '

    '; + var itemDetails = resolve(item.details); + if (itemDetails) out += '

    ' + html.render(itemDetails) + '

    '; }); } }); @@ -1361,6 +1695,8 @@ var ScriptKit = ScriptKit || (() => { }); } setHandoutNotes(handout, out); + // Cache the handout reference + if (reg._handouts) reg._handouts[mode === 'dev' ? 'dev' : 'usr'] = handout; if (msg) { reply(msg, scriptName, mode === 'dev' ? 'Dev Docs' : 'Help', @@ -1686,21 +2022,25 @@ var ScriptKit = ScriptKit || (() => { } const ctx = { selections: g.selections, params: g.params, msg: g.msg, handoutName: g.handoutName, player: getObj('player', g.msg.playerid) }; - const promptText = typeof step.prompt === 'function' ? step.prompt(ctx) : step.prompt; + const promptText = resolve(step.prompt, ctx); let prompt = html.div( html.bold(html.escape(g.handoutName || g.example.name)) + ' โ€” Setup (step ' + interactiveIdx + '/' + interactiveTotal + ')' + html.paragraph('') + html.render(promptText) + html.paragraph('') - + (step.select ? (() => { - const plural = !step.max || step.max > 1; - const label = step.select + (plural ? 's' : ''); + + (resolve(step.select, ctx) ? (() => { + const stepSelect = resolve(step.select, ctx); + const stepMin = resolve(step.min, ctx); + const stepMax = resolve(step.max, ctx); + const plural = !stepMax || stepMax > 1; + const label = stepSelect + (plural ? 's' : ''); const parts = []; - if (step.min) parts.push('min: ' + step.min); - if (step.max) parts.push('max: ' + step.max); + if (stepMin) parts.push('min: ' + stepMin); + if (stepMax) parts.push('max: ' + stepMax); return parts.length > 0 ? html.italic('Select ' + parts.join(', ') + ' ' + label) + html.paragraph('') : ''; })() : '') - + (step.query ? (() => { - const queries = Array.isArray(step.query) ? step.query : [step.query]; + + (resolve(step.query, ctx) ? (() => { + const stepQuery = resolve(step.query, ctx); + const queries = Array.isArray(stepQuery) ? stepQuery : [stepQuery]; const lastVals = g._lastQueryValues || {}; const errors = g._queryErrors || {}; let qOut = ''; @@ -1752,10 +2092,12 @@ var ScriptKit = ScriptKit || (() => { const step = g.steps[g.currentStep]; const selected = (msg.selected || []).map(s => getObj(s._type, s._id)).filter(Boolean); + const ctx = { selections: g.selections, params: g.params, msg: g.msg, handoutName: g.handoutName, player: getObj('player', g.msg.playerid) }; // Handle select steps - if (step.select) { - const selectType = step.select; + var stepSelect = resolve(step.select, ctx); + if (stepSelect) { + const selectType = stepSelect; const isSubtype = selectType === 'token' || selectType === 'card'; const matchType = isSubtype ? 'graphic' : selectType; const filtered = selected.filter(obj => { @@ -1763,26 +2105,29 @@ var ScriptKit = ScriptKit || (() => { if (isSubtype && obj.get('_subtype') !== selectType) return false; return true; }); - const plural = !step.max || step.max > 1; + var stepMin = resolve(step.min, ctx); + var stepMax = resolve(step.max, ctx); + const plural = !stepMax || stepMax > 1; const label = selectType + (plural ? 's' : ''); if (filtered.length === 0) { replyError(msg, g.source, 'Select at least one ' + selectType + ', then click Continue.'); return; } - if (step.min && filtered.length < step.min) { - replyError(msg, g.source, 'Select at least ' + step.min + ' ' + label + ', then click Continue.'); + if (stepMin && filtered.length < stepMin) { + replyError(msg, g.source, 'Select at least ' + stepMin + ' ' + label + ', then click Continue.'); return; } - if (step.max && filtered.length > step.max) { - replyError(msg, g.source, 'Select at most ' + step.max + ' ' + label + ', then click Continue.'); + if (stepMax && filtered.length > stepMax) { + replyError(msg, g.source, 'Select at most ' + stepMax + ' ' + label + ', then click Continue.'); return; } g.selections[step.as] = plural ? filtered : filtered[0]; } // Handle query steps (parse --key value from pre-parsed args, coerce types) - if (step.query) { - const queries = Array.isArray(step.query) ? step.query : [step.query]; + var stepQuery = resolve(step.query, ctx); + if (stepQuery) { + const queries = Array.isArray(stepQuery) ? stepQuery : [stepQuery]; if (!g._lastQueryValues) g._lastQueryValues = {}; var queryErrors = {}; var coercedValues = {}; @@ -2029,6 +2374,207 @@ var ScriptKit = ScriptKit || (() => { // Public API // ========================================================================= + // ========================================================================= + // Usage (unknown command handler) + // ========================================================================= + + /** + * Keyboard-weighted Levenshtein distance. + * Adjacent key substitutions cost less than distant ones. + */ + const levenshtein = (() => { + // QWERTY keyboard layout โ€” adjacency map + const keyPos = {}; + const rows = [['qwertyuiop', 0], ['asdfghjkl', 0.3], ['zxcvbnm', 0.9]]; + rows.forEach(([row, off], r) => { + for (let c = 0; c < row.length; c++) keyPos[row[c]] = { r, c: c + off }; + }); + const keyDist = (a, b) => { + if (a === b) return 0; + const pa = keyPos[a], pb = keyPos[b]; + if (!pa || !pb) return 2; + const dr = Math.abs(pa.r - pb.r), dc = Math.abs(pa.c - pb.c); + if (dr <= 1 && dc <= 1.3) return 0.5; // neighbor + if (dr <= 1 && dc <= 2) return 1; // nearby + return 2; // far + }; + return (a, b) => { + const m = a.length, n = b.length; + const dp = Array.from({ length: m + 1 }, () => Array(n + 1).fill(0)); + for (let i = 0; i <= m; i++) dp[i][0] = i; + for (let j = 0; j <= n; j++) dp[0][j] = j; + for (let i = 1; i <= m; i++) { + for (let j = 1; j <= n; j++) { + if (a[i-1] === b[j-1]) { + dp[i][j] = dp[i-1][j-1]; + } else { + var subCost = dp[i-1][j-1] + keyDist(a[i-1], b[j-1]); + var delCost = dp[i-1][j] + 1; + var insCost = dp[i][j-1] + 1; + dp[i][j] = Math.min(subCost, delCost, insCost); + } + } + } + return dp[m][n]; + }; + })(); + + /** + * Handle unknown commands โ€” suggest corrections, filtered help, or full help. + */ + const handleUsage = (msg, commandOrName, reason) => { + // Auto-detect registration from msg.content command prefix + const cmdPrefix = (msg.content || '').split(/\s+/)[0]; + var scriptName = null; + var reg = null; + Object.keys(registrations).forEach(name => { + if (registrations[name].command === cmdPrefix) { scriptName = name; reg = registrations[name]; } + }); + if (!reg) return; + + // commandOrName is the specific command word (optional) + var command = commandOrName; + + const helpData = reg.help; + + // If a specific command is provided, show its usage directly + if (command) { + var out = ''; + if (reason) out += html.bold('โš  ' + html.escape(reason)) + html.br() + html.br(); + var cmdEntry = null; + if (helpData && helpData.commands) { + var flatCmds = []; + var flatten = (items) => { items.forEach(c => { if (c.group) flatten(c.commands || []); else flatCmds.push(c); }); }; + flatten(helpData.commands); + cmdEntry = flatCmds.find(c => !c.deleted && c.syntax && c.syntax.split(' ')[0].toLowerCase() === command.toLowerCase()); + } + if (cmdEntry) { + out += html.code(reg.command + ' ' + cmdEntry.syntax) + html.br(); + out += html.escape(resolve(cmdEntry.description) || '') + html.br(); + var usageItems = resolve(cmdEntry.items); + if (usageItems && usageItems.length > 0) { + out += html.br(); + usageItems.forEach(item => { + if (resolve(item.deleted)) return; + out += 'โ€ข ' + html.code(resolve(item.name)) + ' โ€” ' + html.escape(resolve(item.description) || '') + html.br(); + }); + } + } else { + out += html.code(reg.command + ' ' + command) + html.br(); + } + reply(msg, scriptName, 'Usage', out); + return; + } + + const content = msg.content.slice(reg.command.length).trim(); + const cmdWord = content.split(/\s+/)[0].toLowerCase(); + if (!cmdWord) { showHelp(msg, scriptName, reg, []); return; } + + if (!helpData) { reply(msg, scriptName, 'Error', 'Unknown command: ' + html.escape(cmdWord)); return; } + + // Collect all known command first-words + var knownCmds = []; + var flatCmds = []; + var flatten = (items) => { items.forEach(c => { if (c.group) flatten(c.commands || []); else flatCmds.push(c); }); }; + if (helpData.commands) flatten(helpData.commands); + flatCmds.forEach(c => { + if (!c.deleted && c.syntax) knownCmds.push(c.syntax.split(' ')[0].toLowerCase()); + }); + // Also include active aliases + Object.values(reg.aliases).forEach(a => { + if (!a) return; + if (Array.isArray(a)) a.forEach(v => knownCmds.push(v.toLowerCase())); + else knownCmds.push(a.toLowerCase()); + }); + knownCmds = [...new Set(knownCmds)]; + + // Also collect topic keys/titles for topic suggestions + var topicNames = []; + if (helpData.topics) { + Object.entries(helpData.topics).forEach(([k, t]) => { + if (!t || t.deleted) return; + topicNames.push({ key: k, title: t.title || k }); + }); + } + + // 1. Fuzzy match against commands + // Threshold scales with input length: short inputs need closer matches + var maxDist = cmdWord.length <= 2 ? 1 : cmdWord.length <= 4 ? 1.5 : 2.5; + var suggestions = knownCmds + .map(c => { + var dist = levenshtein(cmdWord, c); + // Prefix match: if input is a prefix of the command, always suggest + if (c.startsWith(cmdWord)) dist = 0; + else if (cmdWord.startsWith(c)) dist -= c.length * 0.2; + return { cmd: c, dist: dist }; + }) + .filter(s => s.dist <= maxDist) + .sort((a, b) => a.dist - b.dist) + .slice(0, 3); + + // Also check topics (prefix/substring only, no fuzzy) + var topicSuggestions = topicNames + .filter(t => { + var k = t.key.toLowerCase(), title = t.title.toLowerCase(); + return k.startsWith(cmdWord) || title.startsWith(cmdWord) || k.indexOf(cmdWord) !== -1 || title.indexOf(cmdWord) !== -1; + }) + .slice(0, 2); + + if (suggestions.length > 0 || topicSuggestions.length > 0) { + let out = 'Unknown command: ' + html.code(cmdWord) + html.br() + html.br(); + var manAlias = Array.isArray(reg.aliases.man) ? reg.aliases.man[0] : reg.aliases.man; + if (suggestions.length > 0) { + out += 'Did you mean:' + html.br(); + suggestions.forEach(s => { + var cmdEntry = flatCmds.find(c => !c.deleted && c.syntax && c.syntax.split(' ')[0].toLowerCase() === s.cmd); + if (cmdEntry) { + out += 'โ€ข ' + html.code(reg.command + ' ' + cmdEntry.syntax) + ' โ€” ' + html.escape(cmdEntry.description) + html.br(); + } else { + out += 'โ€ข ' + html.code(reg.command + ' ' + s.cmd) + html.br(); + } + }); + } + if (topicSuggestions.length > 0 && manAlias) { + out += (suggestions.length > 0 ? html.br() + 'Or browse a topic:' : 'Did you mean this topic?') + html.br(); + topicSuggestions.forEach(t => { + out += 'โ€ข ' + html.button(t.title, reg.command + ' ' + manAlias + ' ' + t.key) + html.br(); + }); + } + reply(msg, scriptName, 'Help', out); + return; + } + + // 2. Filtered help โ€” does the command word match anything in commands/topics? + var searchResults = []; + flatCmds.forEach(c => { + if (!c.deleted && c.syntax && (c.syntax.toLowerCase().indexOf(cmdWord) !== -1 || (c.description || '').toLowerCase().indexOf(cmdWord) !== -1)) { + searchResults.push(html.code(reg.command + ' ' + c.syntax) + ' โ€” ' + html.escape(c.description)); + } + }); + topicNames.forEach(t => { + if (t.key.toLowerCase().indexOf(cmdWord) !== -1 || t.title.toLowerCase().indexOf(cmdWord) !== -1) { + var manAlias = Array.isArray(reg.aliases.man) ? reg.aliases.man[0] : reg.aliases.man; + searchResults.push(html.button(t.title, reg.command + ' ' + (manAlias || 'man') + ' ' + t.key)); + } + }); + + if (searchResults.length > 0) { + let out = 'Unknown command: ' + html.code(cmdWord) + html.br() + html.br(); + out += 'Related:' + html.br(); + out += html.list(searchResults.slice(0, 5)); + reply(msg, scriptName, 'Help', out); + return; + } + + // 3. Full help fallback โ€” show error + button + var helpAlias = Array.isArray(reg.aliases.help) ? reg.aliases.help[0] : reg.aliases.help; + var out = 'Unknown command: ' + html.code(cmdWord) + html.br() + html.br(); + if (helpAlias) { + out += html.button('๐Ÿ“‹ Show All Commands', reg.command + ' ' + helpAlias); + } + reply(msg, scriptName, 'Help', out); + }; + // ========================================================================= // Public API (Proxy-based for namespace access) // ========================================================================= @@ -2095,6 +2641,15 @@ var ScriptKit = ScriptKit || (() => { if (api._handoutTimers[key]) clearTimeout(api._handoutTimers[key]); api._handoutTimers[key] = setTimeout(() => { delete api._handoutTimers[key]; api.updateHandoutImmediately(scriptName, mode); }, delay); }, + getHelpHandout: (scriptName) => { + const reg = registrations[scriptName]; + return (reg && reg._handouts && reg._handouts.usr) || null; + }, + getDevHandout: (scriptName) => { + const reg = registrations[scriptName]; + return (reg && reg._handouts && reg._handouts.dev) || null; + }, + usage: (msg, command, reason) => handleUsage(msg, command, reason), }; // Tutorial.Choreograph.registerExample('Sequence', { ... }) @@ -2134,17 +2689,46 @@ on('ready', () => { help: { description: 'Generic framework library for Roll20 API scripts.', changelog: [ - { version: '1.2.0', changes: [ + { version: '1.3.0', date: '2026-08-16', changes: [ + 'Lazy evaluation: all registration fields (`description`, `items`, `body`, `title`, `details`, `syntax`, `name`, etc.) can now be functions that return the expected value โ€” evaluated on access', + 'Guide step fields (`select`, `query`, `min`, `max`) also support functions, receiving `ctx` as argument', + 'Man search now resolves dynamic `items` arrays, enabling searchable registry dumps', + 'Fix: code/pre blocks no longer have their content processed as markdown (asterisks, links survive)', + 'Fix: null topic entries no longer crash `man` command', + '`html.table`: wrap in overflow-x:auto div for horizontal scrolling', + '`ScriptKit.getHelpHandout(scriptName)` / `getDevHandout(scriptName)` โ€” cached handout lookup', + '`html.handoutLink` accepts optional anchor parameter for deep-linking to sections', + '`man` topics show ๐Ÿ“– link to handout section', + '`help` command: topics replaced with Browse Topics button; added whatsnew/gen-help/gen-dev-docs to auto-injected commands', + '`!scriptkit whatsnew [date]` โ€” consolidated whatsnew across all plugins with date filtering', + 'Per-plugin `whatsnew` also accepts date argument', + 'Version date tracking: changelog dates stored in state for date-based queries', + '`! changes [search]` โ€” full changelog command with text search', + 'Auto-conflict detection: default aliases matching registered command syntax are auto-nulled at registration', + '`ScriptKit.usage(msg, command?, reason?)` โ€” smart unknown-command handler with keyboard-weighted fuzzy matching, prefix detection, and topic suggestions', + '`! motd` / `!scriptkit motd [plugin]` โ€” on-demand motd with no-repeat tracking and debounced startup delivery', + 'Consolidated "What\'s New" card on startup when plugins upgrade โ€” dismissable, shows changes since last seen', + ]}, + { version: '1.2.0', date: '2026-08-03', changes: [ 'Added `!scriptkit ping` command โ€” ping an object by ID or by coordinates', 'Added `ScriptKit.pingCommand(target, opts)` โ€” build ping command strings for chat links', 'Added `html.pingObjBtn(target, opts)` / `html.pingObjImg(target, opts)` โ€” clickable ping buttons/images', '`visibleTo` now accepts array of player IDs or comma-delimited string', ]}, - { version: '1.1.0', changes: [ + { version: '1.1.0', date: '2026-08-01', changes: [ 'Prevent double-registration (same script calling register() twice is now a no-op)', 'Added ScriptKit.generateHandout / updateHandout (debounced, default 2s) for deferred handout regeneration', 'Added ScriptKit.generateHandoutImmediately / updateHandoutImmediately for synchronous generation', ]}, + { version: '1.0.0', date: '2026-07-20', changes: [ + 'Initial release', + 'Help/Man system with grouped commands, searchable topics, version badges', + 'Handout generation with Quick Start, What\'s New, examples footer', + 'Interactive guide engine with selection, queries, type coercion, validation', + 'State migrations with semver comparison, auto-upgrade, manual rollback', + 'HTML helpers with markdown formatting', + 'Ready signal coordination', + ]}, ], topics: { textRendering: { @@ -2179,29 +2763,62 @@ on('ready', () => { { name: 'onMigrationFailure', description: 'Callback on migration error: ({ version, direction, error, currentStoredVersion }) => void', version: '1.0.0' }, ], }, + usage: { + title: 'Unknown Command Handling', + description: 'Smart suggestions for unrecognized commands', + handouts: 'dev', + version: '1.3.0', + body: 'In your `handleInput`, delegate to ScriptKit first with `if (ScriptKit.handleInput(msg)) return;`. For unknown commands at the end of your handler, call `ScriptKit.usage(msg)` instead of showing a generic error.\n\n' + + '**Fuzzy matching:** Keyboard-weighted Levenshtein (QWERTY adjacency), prefix detection, topic suggestions, then a "Show All Commands" button fallback.\n\n' + + '**Command-specific usage:** Pass a command name to show its syntax and flags: `ScriptKit.usage(msg, "run", "Missing scene name")`', + items: [ + { name: 'ScriptKit.usage(msg)', description: 'Unknown command โ€” fuzzy match and suggest (auto-detects script from msg)', version: '1.3.0' }, + { name: 'ScriptKit.usage(msg, command)', description: 'Show registered usage for a specific command', version: '1.3.0' }, + { name: 'ScriptKit.usage(msg, command, reason)', description: 'Show usage with an error/reason message above', version: '1.3.0' }, + ], + }, helpData: { title: 'Help Data Structure', description: 'Defining commands, topics, and changelog', handouts: 'dev', version: '1.0.0', - body: 'The `help` object defines what appears in chat help, man search, whatsnew, and generated handouts.', + body: 'The `help` object defines what appears in chat help, man search, whatsnew, and generated handouts.\n\n**Lazy evaluation:** Any field (description, items, body, title, details, name, syntax, etc.) can be a function instead of a literal value. ScriptKit calls the function on access and uses the return value. This enables dynamic content that reflects the current state of your registries.', items: [ - { name: 'description', description: 'Short text shown at top of help and handout', version: '1.0.0' }, + { name: 'description', description: 'Short text shown at top of help and handout. Can be a function.', version: '1.0.0' }, { name: 'quickStart', description: 'Array of strings โ€” rendered as ordered list in handout', version: '1.0.0' }, { name: 'changelog', description: 'Array of { version, changes[] } โ€” explicit whatsnew entries', version: '1.0.0' }, { name: 'commands', description: 'Array of { group, commands[] } โ€” grouped command list', version: '1.0.0' }, { name: 'command.syntax', description: 'Usage string (e.g. "run [--flag]")', version: '1.0.0' }, - { name: 'command.description', description: 'Short description for help list', version: '1.0.0' }, - { name: 'command.details', description: 'Longer explanation โ€” handout only', version: '1.0.0' }, - { name: 'command.items', description: 'Sub-items (flags/args) โ€” rendered as bullet list', version: '1.0.0' }, + { name: 'command.description', description: 'Short description for help list. Can be a function.', version: '1.0.0' }, + { name: 'command.details', description: 'Longer explanation โ€” handout only. Can be a function.', version: '1.0.0' }, + { name: 'command.items', description: 'Sub-items (flags/args) โ€” rendered as bullet list. Can be a function returning array.', version: '1.0.0' }, { name: 'command.version', description: 'Version when added โ€” used for [new] badge', version: '1.0.0' }, { name: 'topics', description: 'Object keyed by id โ€” detailed help topics for man/handout', version: '1.0.0' }, - { name: 'topic.title', description: 'Display title', version: '1.0.0' }, + { name: 'topic.title', description: 'Display title. Can be a function.', version: '1.0.0' }, { name: 'topic.body', description: 'Main content โ€” string or () => string', version: '1.0.0' }, { name: 'topic.handouts', description: '"usr" (default) | "dev" | ["usr","dev"] | null (man-only)', version: '1.0.0' }, - { name: 'topic.items', description: 'Array of { name, description, version } sub-items', version: '1.0.0' }, + { name: 'topic.items', description: 'Array of { name, description, version } sub-items. Can be a function returning array.', version: '1.0.0' }, ], }, + lazyEval: { + title: 'Lazy Evaluation', + description: 'Using functions for dynamic registration fields', + handouts: 'dev', + version: '1.3.0', + body: 'Any registration field can be a **function** instead of a static value. ScriptKit resolves it on access โ€” calling the function and using the return value.\n\n' + + 'This is useful for scripts with dynamic registries (e.g. listing all registered attributes, functions, or extensions) where the content isn\'t known until runtime.\n\n' + + '**Help/Man/Handout fields** โ€” called with no arguments:\n' + + '`description`, `title`, `body`, `items`, `details`, `name`, `syntax`, `deleted`, `deprecated`\n\n' + + '**Guide step fields** โ€” called with `ctx` ({ selections, params, msg, handoutName, player }):\n' + + '`prompt`, `select`, `query`, `min`, `max`\n\n' + + '**Example:**\n' + + '```items: () => Object.values(myRegistry).map(r => ({\n' + + ' name: r.name,\n' + + ' description: r.description,\n' + + ' version: \'1.0.0\'\n' + + '}))```\n\n' + + 'This makes the items searchable via `man` and always up-to-date in handouts.', + }, examples: { title: 'Registering Examples', description: 'How to add examples for any script', @@ -2224,7 +2841,7 @@ on('ready', () => { description: 'Interactive wizard step API', handouts: 'dev', version: '1.0.0', - body: 'Each step is an object with `prompt` (string or (ctx) => string, supports `` `code` ``, `**bold**`, `*italic*`).\n\n**ctx object:** `selections`, `params`, `msg`, `handoutName`, `selected`\n\nSteps render with hue-rotating backgrounds for visual progression. Errors support markdown formatting.', + body: 'Each step is an object with `prompt` (string or (ctx) => string, supports `` `code` ``, `**bold**`, `*italic*`).\n\n**ctx object:** `selections`, `params`, `msg`, `handoutName`, `selected`\n\n**Lazy evaluation:** `prompt`, `select`, `query`, `min`, and `max` can all be functions receiving `ctx` โ€” use this for steps that adapt based on earlier selections.\n\nSteps render with hue-rotating backgrounds for visual progression. Errors support markdown formatting.', items: [ { name: 'prompt', description: 'String or (ctx) => string โ€” step text with markdown support', version: '1.0.0' }, { name: 'select', description: 'Roll20 _type filter: "token", "card", "pin", "path"', version: '1.0.0' }, @@ -2281,7 +2898,7 @@ on('ready', () => { { name: 'table(headers, rows, style?)', description: 'HTML table', version: '1.0.0' }, { name: 'list(items)', description: 'Unordered list (<ul>)', version: '1.0.0' }, { name: 'orderedList(items)', description: 'Ordered list (<ol>)', version: '1.0.0' }, - { name: 'handoutLink(text, id)', description: 'Journal link', version: '1.0.0' }, + { name: 'handoutLink(text, id, style?, anchor?)', description: 'Journal link. Optional anchor param deep-links to a heading section.', version: '1.0.0' }, { name: 'newBadge() / deprecatedBadge()', description: 'Version badge markers', version: '1.0.0' }, { name: 'style(obj)', description: 'Convert camelCase object to CSS string', version: '1.0.0' }, { name: 'pingObjBtn(target, opts?)', description: 'Clickable text button that pings an object location. opts: color, moveAll, visibleTo, label, style', version: '1.2.0' }, @@ -2325,14 +2942,16 @@ on('ready', () => { handouts: 'dev', version: '1.1.0', body: 'If your script has topics with dynamic content (e.g. listing registered extensions), the handout generated at startup may be incomplete โ€” other scripts register after yours.\n\n' - + '**Step 1: Function bodies** โ€” Use `body: () => ...` in topics. Chat commands (`man`) always evaluate live, giving users current info. Without this, content is static and regeneration has no effect.\n\n' - + '**Step 2: Programmatic regeneration** โ€” Call `ScriptKit.generateHandout()` or `ScriptKit.updateHandout()` after extensions finish registering. This re-renders the handout, invoking your function bodies with up-to-date data.\n\n' + + '**Step 1: Function fields** โ€” Use `body: () => ...` and/or `items: () => [...]` in topics. Chat commands (`man`) always evaluate live, giving users current info. Dynamic `items` are also searchable via `man`.\n\n' + + '**Step 2: Programmatic regeneration** โ€” Call `ScriptKit.generateHandout()` or `ScriptKit.updateHandout()` after extensions finish registering. This re-renders the handout, invoking your function fields with up-to-date data.\n\n' + '**Pattern:** Debounce a timer in your extension registration functions. After the last registration, the timer fires and regenerates the handout once.', items: [ { name: 'ScriptKit.generateHandout(scriptName, mode, delay?)', description: 'Schedule handout generation (debounced, default 2000ms). Creates handout if missing. Repeated calls reset the timer.', version: '1.1.0' }, { name: 'ScriptKit.updateHandout(scriptName, mode, delay?)', description: 'Schedule handout regeneration only if it already exists (debounced, default 2000ms). No-op if handout is missing.', version: '1.1.0' }, { name: 'ScriptKit.generateHandoutImmediately(scriptName, mode)', description: 'Generate handout synchronously without debounce. Creates if missing.', version: '1.1.0' }, { name: 'ScriptKit.updateHandoutImmediately(scriptName, mode)', description: 'Regenerate handout synchronously only if it exists.', version: '1.1.0' }, + { name: 'ScriptKit.getHelpHandout(scriptName)', description: 'Returns the cached user help handout object for a script (or null). No findObjs call.', version: '1.3.0' }, + { name: 'ScriptKit.getDevHandout(scriptName)', description: 'Returns the cached dev docs handout object for a script (or null). No findObjs call.', version: '1.3.0' }, { name: 'body: () => string', description: 'Topic body as a function โ€” evaluated at render time (chat man) and at handout generation. Always reflects current state.', version: '1.0.0' }, ], }, diff --git a/ScriptKit/TODO.md b/ScriptKit/TODO.md index d328f1cc6..6fd89f337 100644 --- a/ScriptKit/TODO.md +++ b/ScriptKit/TODO.md @@ -1,12 +1,51 @@ # ScriptKit TODO +## Done (v1.3.0) + +- [x] Characters within code/pre blocks un-formatted (asterisks and links preserved inside backtick code spans) +- [x] html.table: overflow-x:auto wrapper, white-space:nowrap on headers +- [x] Null topic guard in man command +- [x] `ScriptKit.getHelpHandout(scriptName)` and `ScriptKit.getDevHandout(scriptName)` โ€” cached handout lookup +- [x] `html.handoutLink(text, id, style, anchor)` โ€” optional anchor param for deep-linking to handout sections +- [x] `help` should not inline topics list โ€” replaced with Browse Topics button +- [x] `help` auto-injected commands โ€” now shows whatsnew, gen-help, gen-dev-docs (conditionally) +- [x] `!scriptkit whatsnew [date]` โ€” consolidated whatsnew across all registered plugins with date filtering +- [x] Per-plugin whatsnew accepts date argument +- [x] Version date tracking โ€” changelog dates stored in state, current version auto-stamped +- [x] `man` topics show ๐Ÿ“– link to handout section +- [x] `! changes` โ€” show the full rendered changelog (not just new stuff) +- [x] `ScriptKit.usage(msg, command?, reason?)` โ€” smart unknown-command handler with fuzzy matching, prefix detection, topic suggestions +- [x] `! motd` / `!scriptkit motd [plugin]` โ€” on-demand motd with no-repeat tracking, debounced startup, derived button styling +- [x] MOTD batching: debounced delivery (10s after last registration), single tip from global pool +- [x] Consolidated "What's New" card on startup: shows changes since last seen, dismissable + ## Planned Features -### `ScriptKit.MyScript.usage(msg)` -API that consumer scripts can call from their default/unknown-command handler. Could: -- Show the full help text (like `showHelp`) -- Fuzzy-match the unrecognized subcommand against registered commands and suggest "Did you mean X?" -- Whisper the suggestion to the player who sent the message +### Example Actions +Custom buttons shown in the examples menu for already-generated examples. Allows scripts to provide contextual actions (e.g. Play/Loop for Sequence, Run for Choreograph) directly in the menu without requiring regeneration. + +Possible API: +```js +ScriptKit.MyScript.registerExample('MyScript', { + name: 'my-example', + actions: (example, handout) => [ + { label: 'โ–ถ Play', command: '!sequence play ' + recName }, + { label: '๐Ÿ” Loop', command: '!sequence play ' + recName + ' --loop' }, + ], +}); +``` + +Actions render as buttons in the examples menu when the handout already exists, alongside Regen/Open. + +### MOTD +- [ ] MOTD configuration menu: per-motd and per-plugin state toggling (A: never, B: per-plugin pool, C: global pool, D: inherit) + +### State & Migrations +- [ ] Rollback safety: brainstorm options for downgrade handling (throw, auto-run safe downs, disable handler) +- [ ] State wipe command: `!scriptkit reset ` to clear state completely + +### Version Tagging +- [ ] Multi-script version tagging: allow `version` on items/topics/commands to be string, object `{ scriptName, version }`, or array โ€” lets extension content get proper [new] badges based on the extending script's version -### CompareVersion override -- `compareVersion` override in registration opts (custom comparator for [new] badges and determining whether migrations should go up or down) +### Other +- [ ] `compareVersion` override in registration opts (custom comparator for [new] badges and migration direction) diff --git a/ScriptKit/script.json b/ScriptKit/script.json index f8cffea1c..1aa500b60 100644 --- a/ScriptKit/script.json +++ b/ScriptKit/script.json @@ -1,12 +1,13 @@ { "name": "ScriptKit", "script": "ScriptKit.js", - "version": "1.2.0", + "version": "1.3.0", "previousversions": [ "1.0.0", - "1.1.0" + "1.1.0", + "1.2.0" ], - "description": "Generic framework library for Roll20 API scripts. Not a standalone script -- provides infrastructure for other scripts to build on.\n\nFeatures:\n- **Help & Man system** -- Structured command reference with grouped commands, searchable topics, version badges, and What's New display\n- **Handout generation** -- Auto-generated help handouts on version bump with Quick Start, commands, topics, and examples footer\n- **Examples & Guides** -- Interactive multi-step setup wizards with token/pin selection, query prompts with type coercion, and validation\n- **State migrations** -- Semver-based sequential up/down migrations with auto-upgrade and manual rollback\n- **Ready signal coordination** -- `!scriptkit-ready` broadcast for load-order-independent registration\n- **HTML helpers** -- Full suite of formatting functions (escape, bold, italic, code, table, button, format with markdown support)\n\nScripts register via `ScriptKit.register(name, opts)` and gain help, man, examples, whatsnew, and handout generation commands automatically.\n\nFor script developers -- see the generated Help handout or run `! help` after registering.\n\n**v1.2.0 - New features:**\n- Added `!scriptkit ping` command โ€” ping an object by ID or by coordinates\n- Added `ScriptKit.pingCommand(target, opts)` โ€” build ping command strings for chat links\n- Added `html.pingObjBtn(target, opts)` / `html.pingObjImg(target, opts)` โ€” clickable ping buttons/images\n- `visibleTo` now accepts array of player IDs or comma-delimited string", + "description": "Generic framework library for Roll20 API scripts. Not a standalone script -- provides infrastructure for other scripts to build on.\n\nFeatures:\n- **Help & Man system** -- Structured command reference with grouped commands, searchable topics, version badges, and What's New display\n- **Handout generation** -- Auto-generated help handouts on version bump with Quick Start, commands, topics, and examples footer\n- **Examples & Guides** -- Interactive multi-step setup wizards with token/pin selection, query prompts with type coercion, and validation\n- **State migrations** -- Semver-based sequential up/down migrations with auto-upgrade and manual rollback\n- **Ready signal coordination** -- `!scriptkit-ready` broadcast for load-order-independent registration\n- **HTML helpers** -- Full suite of formatting functions (escape, bold, italic, code, table, button, format with markdown support)\n\nScripts register via `ScriptKit.register(name, opts)` and gain help, man, examples, whatsnew, and handout generation commands automatically.\n\nFor script developers -- see the generated Help handout or run `! help` after registering.\n\n**v1.3.0 - New features:**\n- Lazy evaluation: all registration fields (`description`, `items`, `body`, `title`, `details`, `syntax`, `name`, etc.) can now be functions that return the expected value โ€” evaluated on access\n- Guide step fields (`select`, `query`, `min`, `max`) also support functions, receiving `ctx` as argument\n- Man search now resolves dynamic `items` arrays, enabling searchable registry dumps\n- Fix: code/pre blocks no longer have their content processed as markdown (asterisks, links survive)\n- Fix: null topic entries no longer crash `man` command\n- `html.table`: wrap in overflow-x:auto div for horizontal scrolling\n- `ScriptKit.getHelpHandout(scriptName)` / `getDevHandout(scriptName)` โ€” cached handout lookup\n- `html.handoutLink` accepts optional anchor parameter for deep-linking to sections\n- `man` topics show ๐Ÿ“– link to handout section\n- `help` command: topics replaced with Browse Topics button; added whatsnew/gen-help/gen-dev-docs to auto-injected commands\n- `!scriptkit whatsnew [date]` โ€” consolidated whatsnew across all plugins with date filtering\n- Per-plugin `whatsnew` also accepts date argument\n- Version date tracking: changelog dates stored in state for date-based queries\n- `! changes [search]` โ€” full changelog command with text search\n- Auto-conflict detection: default aliases matching registered command syntax are auto-nulled at registration\n- `ScriptKit.usage(msg, command?, reason?)` โ€” smart unknown-command handler with keyboard-weighted fuzzy matching, prefix detection, and topic suggestions\n- `! motd` / `!scriptkit motd [plugin]` โ€” on-demand motd with no-repeat tracking and debounced startup delivery\n- Consolidated \"What\\'s New\" card on startup when plugins upgrade โ€” dismissable, shows changes since last seen", "authors": "Kenan Millet", "roll20userid": "2614613", "dependencies": [],