// ChatSetAttr version 1.2.1 // Last Updated: 2017-01-30 // A script to create, modify, or delete character attributes from the chat area or macros. // If you don't like my choices for --replace, you can edit the replacers variable at your own peril to change them. var chatSetAttr = chatSetAttr || (function () { 'use strict'; const version = '1.2', schemaVersion = 3, replacers = [ ['<', '[', /', ']', />/g, /\]/g], ['~', '-', /\~/g, /\-/g], [';', '?', /\;/g, /\?/g], ['`', '@', /`/g, /@/g] ], checkInstall = function () { log(`-=> ChatSetAttr v${version} <=-`); if (!_.has(state, 'ChatSetAttr') || state.ChatSetAttr.version !== schemaVersion) { log(` > Updating ChatSetAttr Schema to v${schemaVersion} <`); state.ChatSetAttr = { version: schemaVersion, globalconfigCache: { lastsaved: 0 }, playersCanModify: false, playersCanEvaluate: false, useWorkers: true }; } checkGlobalConfig(); }, isDef = function (value) { return !_.isUndefined(value); }, getWhisperPrefix = function (playerid) { let player = getObj('player', playerid); if (player && player.get('_displayname')) { return '/w "' + player.get('_displayname') + '" '; } else { return '/w GM '; } }, sendChatMessage = function (msg) { sendChat('ChatSetAttr', msg, null, { noarchive: true }); }, setAttribute = function (attr, value) { state.ChatSetAttr.useWorkers ? attr.setWithWorker(value) : attr.set(value); }, checkGlobalConfig = function () { let s = state.ChatSetAttr, g = globalconfig && globalconfig.chatsetattr; if (g && g.lastsaved && g.lastsaved > s.globalconfigCache.lastsaved) { log(' > Updating ChatSetAttr from Global Config < [' + (new Date(g.lastsaved * 1000)) + ']'); s.playersCanModify = 'playersCanModify' === g['Players can modify all characters']; s.playersCanEvaluate = 'playersCanEvaluate' === g['Players can use --evaluate']; s.useWorkers = 'useWorkers' === g['Trigger sheet workers when setting attributes']; s.globalconfigCache = globalconfig.chatsetattr; } }, handleErrors = function (whisper, errors) { if (errors.length) { let output = whisper + '

Errors

` + `

${errors.join('
')}

`; sendChatMessage(output); errors.splice(0, errors.length); } }, showConfig = function (whisper) { let optionsText = _.map([{ name: 'playersCanModify', command: 'players-can-modify', desc: 'Determines if players can use --name and --charid to ' + 'change attributes of characters they do not control.' }, { name: 'playersCanEvaluate', command: 'players-can-evaluate', desc: 'Determines if players can use the --evaluate option. ' + 'Be careful in giving players access to this option, because ' + 'it potentially gives players access to your full API sandbox.' }, { name: 'useWorkers', command: 'use-workers', desc: 'Determines if setting attributes should trigger sheet worker operations.' }], getConfigOptionText).join(''); let output = whisper + '
ChatSetAttr Configuration
' + '

!setattr-config can be invoked in the following format:

!setattr-config --option
' + '

Specifying an option toggles the current setting. There are currently two' + ' configuration options:

' + optionsText + '
'; sendChatMessage(output); }, getConfigOptionText = function (o) { let button = (state.ChatSetAttr[o.name] ? 'ON' : 'OFF' ); return '
${o.name} is currently ${button}` + `Toggle
`; }, getCharNameById = function (id) { let character = getObj('character', id); return (character) ? character.get('name') : ''; }, escapeRegExp = function (str) { return str.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g, "\\$&"); }, htmlReplace = function (str) { let entities = { '<': 'lt', '>': 'gt', "'": '#39', '@': '#64', '{': '#123', '|': '#124', '}': '#125', '[': '#91', ']': '#93', '"': 'quot', }; return _.chain(str.split('')) .map(c => (_.has(entities, c)) ? ('&' + entities[c] + ';') : c) .value() .join(''); }, processInlinerolls = function (msg) { if (_.has(msg, 'inlinerolls')) { return _.chain(msg.inlinerolls) .reduce(function (m, v, k) { let ti = _.reduce(v.results.rolls, function (m2, v2) { if (_.has(v2, 'table')) { m2.push(_.reduce(v2.results, function (m3, v3) { m3.push(v3.tableItem.name); return m3; }, []).join(', ')); } return m2; }, []).join(', '); m['$[[' + k + ']]'] = (ti.length && ti) || v.results.total || 0; return m; }, {}) .reduce((m, v, k) => m.replace(k, v), msg.content) .value(); } else { return msg.content; } }, getCIKey = function (obj, name) { let nameLower = name.toLowerCase(), result = false; _.each(obj, function (v, k) { if (k.toLowerCase() === nameLower) { result = k; } }); return result; }, generateUUID = function () { "use strict"; var a = 0, b = []; return function () { var c = (new Date()).getTime() + 0, d = c === a; a = c; for (var e = new Array(8), f = 7; 0 <= f; f--) { e[f] = "-0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqrstuvwxyz".charAt(c % 64); c = Math.floor(c / 64); } c = e.join(""); if (d) { for (f = 11; 0 <= f && 63 === b[f]; f--) { b[f] = 0; } b[f]++; } else { for (f = 0; 12 > f; f++) { b[f] = Math.floor(64 * Math.random()); } } for (f = 0; 12 > f; f++) { c += "-0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqrstuvwxyz".charAt(b[f]); } return c; }; }(), generateRowID = function () { "use strict"; return generateUUID().replace(/_/g, "Z"); }, // Getting attributes from parsed options. Repeating attributes need special treatment // in order to parse row index and not create defective repeating rows. addRepeatingAttributes = function (list, attrNames, allAttrs, errors, createMissing, failSilently) { let allRepAttrs = {}, repRowIds = {}, repRowIdsLo = {}, rowsToCreate = [], repSections = [], repOrders = {}; // Read attribute names, determine row number or id or creation of new row. let attrNamesRead = _.reduce(attrNames, function (prev, attrName) { let match = attrName.match(/_(\$\d+|-[-A-Za-z0-9]+?|\d+)_/); if (match && match[1][0] === '$') { prev[attrName] = { rowNum: parseInt(match[1].slice(1)) }; } else if (match) { prev[attrName] = { rowIdLo: match[1].toLowerCase() }; } else { errors.push(`Could not understand repeating attribute` + ` name ${attrName}.`); return; } prev[attrName]['sName'] = attrName.split(match[0]); repSections.push(prev[attrName]['sName'][0]); if (prev[attrName].rowIdLo === '-create') { rowsToCreate.push(prev[attrName]['sName'][0]); } return prev; }, {}); repSections = _.uniq(repSections); rowsToCreate = _.uniq(rowsToCreate); let repSectionsRegex = _.map(repSections, function (s) { return new RegExp('^' + escapeRegExp(s) + '_(-[-A-Za-z0-9]+?|\\d+)_', 'i'); }); _.each(list, function (charid) { allRepAttrs[charid] = {}; repOrders[charid] = {}; repRowIds[charid] = {}; _.each(repSections, prefix => allRepAttrs[charid][prefix] = {}); // Get attributes _.each(findObjs({ _type: 'attribute', _characterid: charid }), function (o) { let attrName = o.get('name'); _.each(repSections, function (prefix, k) { if (attrName.search(repSectionsRegex[k]) === 0) { allRepAttrs[charid][prefix][attrName] = o; } else if (attrName === '_reporder_' + prefix) { repOrders[charid][prefix] = o.get('current').split(','); } }); }); // Get list of repeating row ids by prefix from allRepAttrs _.each(repSections, function (prefix, k) { repRowIds[charid][prefix] = _.chain(allRepAttrs[charid][prefix]) .keys() .map(n => n.match(repSectionsRegex[k])) .compact() .map(a => a[1]) .uniq() .value(); if (repOrders[charid][prefix]) { repRowIds[charid][prefix] = _.chain(repOrders[charid][prefix]) .intersection(repRowIds[charid][prefix]) .union(repRowIds[charid][prefix]) .value(); } }); repRowIdsLo[charid] = _.mapObject(repRowIds[charid], l => _.map(l, n => n.toLowerCase())); _.each(rowsToCreate, function (prefix) { repRowIds[charid][prefix].push(generateRowID()); }); // Get correct attribute names and store attributes in allAttrs _.each(attrNamesRead, function (p, attrName) { let finalId; if (isDef(p.rowNum) && isDef(repRowIds[charid][p.sName[0]][p.rowNum])) { finalId = repRowIds[charid][p.sName[0]][p.rowNum]; } else if (p.rowIdLo === '-create') { finalId = _.last(repRowIds[charid][p.sName[0]]); } else if (isDef(p.rowIdLo) && _.contains(repRowIdsLo[charid][p.sName[0]], p.rowIdLo)) { let index = _.indexOf(repRowIdsLo[charid][p.sName[0]], p.rowIdLo); finalId = repRowIds[charid][p.sName[0]][index]; } else if (isDef(p.rowNum)) { errors.push(`Repeating row number ${p.rowNum} invalid for` + ` character ${getCharNameById(charid)}` + ` and repeating section ${p.sName[0]}.`); } else { errors.push(`Repeating row id ${p.rowIdLo} invalid for` + ` character ${getCharNameById(charid)}` + ` and repeating section ${p.sName[0]}.`); } if (finalId) { let finalName = p.sName[0] + '_' + finalId + '_' + p.sName[1], attrNameCased = getCIKey(allRepAttrs[charid][p.sName[0]], finalName); if (attrNameCased) { allAttrs[charid][attrName] = allRepAttrs[charid][p.sName[0]][attrNameCased]; } else if (createMissing) { allAttrs[charid][attrName] = createObj('attribute', { characterid: charid, name: finalName }); } else if (!failSilently) { errors.push(`Missing attribute ${finalName} not created` + ` for character ${getCharNameById(charid)}.`); } } }); }); return; }, addStandardAttributes = function (list, attrNames, allAttrs, errors, createMissing, failSilently) { let attrNamesUpper = attrNames.map(x => x.toUpperCase()), nameIndex; _.each(list, function (charid) { _.each(findObjs({ _type: 'attribute', _characterid: charid }), function (o) { nameIndex = _.indexOf(attrNamesUpper, o.get('name').toUpperCase()); if (nameIndex !== -1) { allAttrs[charid][attrNames[nameIndex]] = o; } }); _.each(_.difference(attrNames, _.keys(allAttrs[charid])), function (key) { if (createMissing) { allAttrs[charid][key] = createObj('attribute', { characterid: charid, name: key }); } else if (!failSilently) { errors.push(`Missing attribute ${key} not created for` + ` character ${getCharNameById(charid)}.`); } }); }); }, getAllAttributes = function (list, attrNames, errors, createMissing, failSilently) { let allAttrs = {}, attrNamesRepeating = _.filter(attrNames, str => (str.search(/^repeating_/) === 0)); _.each(list, charid => allAttrs[charid] = {}); addStandardAttributes(list, _.difference(attrNames, attrNamesRepeating), allAttrs, errors, createMissing, failSilently); addRepeatingAttributes(list, attrNamesRepeating, allAttrs, errors, createMissing, failSilently); return allAttrs; }, // Setting attributes happens in a delayed recursive way to prevent the sandbox // from overheating. delayedSetAttributes = function (whisper, list, setting, errors, allAttrs, fillInAttrs, opts) { let cList = _.clone(list), feedback = [], dWork = function (charid) { setCharAttributes(charid, setting, errors, feedback, allAttrs[charid], fillInAttrs, opts); if (cList.length) { _.delay(dWork, 50, cList.shift()); } else { handleErrors(whisper, errors); (opts.silent) ? null: sendFeedback(whisper, feedback); } } dWork(cList.shift()); }, setCharAttributes = function (charid, setting, errors, feedback, attrs, fillInAttrs, opts) { let charFeedback = {}; _.each(attrs, function (attr, attrName) { let newValue; if (opts.reset) { newValue = { current: attr.get('max') }; } else { newValue = (fillInAttrs[attrName]) ? _.mapObject(setting[attrName], v => fillInAttrValues(charid, v)) : _.clone(setting[attrName]); } if (opts.evaluate) { try { newValue = _.mapObject(newValue, function (v) { let parsed = eval(v); if (_.isString(parsed) || _.isFinite(parsed) || _.isBoolean(parsed)) { return parsed.toString(); } else return v; }); } catch (err) { errors.push(`Something went wrong with --evaluate` + ` for the character ${getCharNameById(charid)}.` + ` You were warned. The error message was: ${err}.` + ` Attribute ${attrName} left unchanged.`); return; } } if (opts.mod || opts.modb) { _.each(newValue, function (v, k) { let moddedValue = parseFloat(v) + parseFloat(attr.get(k) || '0'); if (!_.isNaN(moddedValue)) { if (opts.modb && k === 'current') { moddedValue = Math.min(Math.max(moddedValue, 0), parseFloat(attr.get('max') || Infinity)); } newValue[k] = moddedValue.toString(); } else { delete newValue[k]; let type = (k === 'max') ? 'maximum ' : ''; errors.push(`Attribute ${type}${attrName} is not number-valued` + ` for character ${getCharNameById(charid)}.` + ` Attribute ${type}left unchanged.`); } }); } charFeedback[attrName] = newValue; setAttribute(attr, newValue); }); // Feedback if (!opts.silent) { charFeedback = _.chain(charFeedback) .map(function (o, k) { if (isDef(o.max) && isDef(o.current)) return `${k} to ${htmlReplace(o.current) || '(empty)'} / ${htmlReplace(o.max) || '(empty)'}`; else if (isDef(o.current)) return `${k} to ${htmlReplace(o.current) || '(empty)'}`; else if (isDef(o.max)) return `${k} to ${htmlReplace(o.max) || '(empty)'} (max)`; else return null; }) .compact() .value(); if (!_.isEmpty(charFeedback)) { feedback.push(`Setting ${charFeedback.join(', ')} for` + ` character ${getCharNameById(charid)}.`); } else { feedback.push(`Nothing to do for character` + ` ${getCharNameById(charid)}.`); } } return; }, fillInAttrValues = function (charid, expression) { let match = expression.match(/%(\S.*?)(?:_(max))?%/), replacer; while (match) { replacer = getAttrByName(charid, match[1], match[2] || 'current') || ''; expression = expression.replace(/%(\S.*?)(?:_(max))?%/, replacer); match = expression.match(/%(\S.*?)(?:_(max))?%/); } return expression; }, deleteAttributes = function (whisper, allAttrs, silent) { let feedback = {}; _.each(allAttrs, function (charAttrs, charid) { feedback[charid] = []; _.each(charAttrs, function (attr, name) { attr.remove(); feedback[charid].push(name); }); }); silent ? null : sendDeleteFeedback(whisper, feedback); }, // These functions parse the chat input. parseOpts = function (content, hasValue) { // Input: content - string of the form command --opts1 --opts2 value --opts3. // values come separated by whitespace. // hasValue - array of all options which come with a value // Output: object containing key:true if key is not in hasValue. and containing // key:value otherwise let opts = {}; let args = _.rest(content.replace(/\n/g, ' ') .replace(/\s*$/g, '') .replace(/({{(.*?)\s*}}$)/g, '$2') .split(/\s+--/)); _.each(args, function (arg) { let kv = arg.split(/\s(.+)/); if (_.contains(hasValue, kv[0])) { opts[kv[0]] = kv[1]; } else { opts[arg] = true; } }); return opts; }, parseAttributes = function (args, replace, fillInAttrs) { // Input: args - array containing comma-separated list of strings, every one of which contains // an expression of the form key|value or key|value|maxvalue // replace - true if characters from the replacers array should be replaced // Output: Object containing key|value for all expressions. let setting = _.chain(args) .map(str => str.split(/(\\?(?:#|\|))/g)) .map(function (split) { _.each(split, function (str, k) { if ((str === '#' || str === '|')) { split[k] = null; } else if ((str === '\\#' || str === '\\|')) { split[k] = (split[k - 1] || '') + str.slice(-1) + (split[k + 1] || ''); (k > 0) ? (split[k - 1] = null) : null; (k < split.length - 1) ? (split[k + 1] = null) : null; } }); return _.reject(split, _.isNull); }) .compact() .map(readAttribute) .reduce(function (p, c) { p[c[0]] = _.extend(p[c[0]] || {}, c[1]) return p; }, {}) .value(); if (replace) { setting = _.mapObject(setting, function (obj) { return _.mapObject(obj, function (str) { _.each(replacers, function (rep) { str = str.replace(rep[2], rep[1]); }); return str; }); }); } _.extend(fillInAttrs, _.mapObject(setting, obj => (obj.current && obj.current.search(/%(\S.*?)(?:_(max))?%/) !== -1) || (obj.max && obj.max.search(/%(\S.*?)(?:_(max))?%/) !== -1) )); return setting; }, readAttribute = function (arr) { let value = {}; if (arr.length < 3 || arr[1] !== '') { value.current = arr[1] || ''; } if (arr.length > 2) { value.max = arr[2]; } return [arr[0], _.mapObject(value, str => str.replace(/^'(.*)'$/, '$1'))]; }, // These functions are used to get a list of character ids from the input, // and check for permissions. checkPermissions = function (list, errors, playerid, isGM) { let control, character; _.each(list, function (id, k) { character = getObj('character', id); if (character) { control = character.get('controlledby').split(/,/); if (!(state.ChatSetAttr.playersCanModify || isGM || _.contains(control, 'all') || _.contains(control, playerid))) { list[k] = null; errors.push(`Permission error for character ${character.get('name')}.`); } } else { errors.push(`Invalid character id ${id}.`); list[k] = null; } }); return _.compact(list); }, getIDsFromTokens = function (selected) { return _.chain(selected) .map(obj => getObj('graphic', obj._id)) .compact() .map(token => token.get('represents')) .compact() .filter(id => getObj('character', id)) .value(); }, getIDsFromNames = function (charNames, errors) { return _.chain(charNames.split(/\s*,\s*/)) .map(function (name) { let character = findObjs({ _type: 'character', name: name }, { caseInsensitive: true })[0]; if (character) { return character.id; } else { errors.push('No character named ' + name + ' found.'); return null; } }) .compact() .value(); }, sendFeedback = function (whisper, feedback) { let output = whisper + '

Setting attributes

' + (feedback.join('
') || 'Nothing to do.') + '

'; sendChatMessage(output); }, sendDeleteFeedback = function (whisper, feedback) { let output = whisper + '

Deleting attributes

'; output += _.chain(feedback) .omit(arr => _.isEmpty(arr)) .map(function (arr, charid) { return `Deleting attribute(s) ${arr.join(', ')} for character` + ` ${getCharNameById(charid)}.`; }) .join('
') .value() || 'Nothing to do.'; output += '

'; sendChatMessage(output); }, // Main function, called after chat message input handleInput = function (msg) { if (msg.type !== 'api') { return; } const mode = msg.content.match(/^!(reset|set|del|mod)attr\b(?:-|\s|$)(config)?/), whisper = getWhisperPrefix(msg.playerid); if (mode && mode[2]) { if (playerIsGM(msg.playerid)) { let opts = parseOpts(msg.content, []); if (opts['players-can-modify']) { state.ChatSetAttr.playersCanModify = !state.ChatSetAttr.playersCanModify; } if (opts['players-can-evaluate']) { state.ChatSetAttr.playersCanEvaluate = !state.ChatSetAttr.playersCanEvaluate; } if (opts['use-workers']) { state.ChatSetAttr.useWorkers = !state.ChatSetAttr.useWorkers; } showConfig(whisper); } } else if (mode) { // Parsing input let charIDList = [], fillInAttrs = {}, errors = []; const hasValue = ['charid', 'name'], optsArray = ['all', 'allgm', 'charid', 'name', 'allplayers', 'sel', 'replace', 'nocreate', 'mod', 'modb', 'evaluate', 'silent', 'reset' ], opts = parseOpts(processInlinerolls(msg), hasValue), setting = parseAttributes(_.chain(opts).omit(optsArray).keys().value(), opts.replace, fillInAttrs), deleteMode = (mode[1] === 'del'), isGM = msg.playerid === 'API' || playerIsGM(msg.playerid); opts.mod = opts.mod || (mode[1] === 'mod'); opts.reset = opts.reset || (mode[1] === 'reset'); if (opts.evaluate && !isGM && !state.ChatSetAttr.playersCanEvaluate) { handleErrors(whisper, ['The --evaluate option is only available to the GM.']); return; } // Get list of character IDs if (opts.all && isGM) { charIDList = _.map(findObjs({ _type: 'character' }), c => c.id); } else if (opts.allgm && isGM) { charIDList = _.chain(findObjs({ _type: 'character' })) .filter(c => c.get('controlledby') === '') .map(c => c.id) .value(); } else if (opts.allplayers && isGM) { charIDList = _.chain(findObjs({ _type: 'character' })) .filter(c => c.get('controlledby') !== '') .map(c => c.id) .value(); } else { (opts.charid) ? charIDList.push(...opts.charid.split(/\s*,\s*/)): null; (opts.name) ? charIDList.push(...getIDsFromNames(opts.name, errors)): null; (opts.sel) ? charIDList.push(...getIDsFromTokens(msg.selected)): null; charIDList = checkPermissions(_.uniq(charIDList), errors, msg.playerid, isGM); } if (_.isEmpty(charIDList)) { errors.push('No target characters. You need to supply one of --all, --allgm, --sel,' + ' --allplayers, --charid, or --name.'); } if (_.isEmpty(setting)) { errors.push('No attributes supplied.'); } // Get attributes let allAttrs = getAllAttributes(charIDList, _.keys(setting), errors, !opts.nocreate && !deleteMode, deleteMode); handleErrors(whisper, errors); // Set or delete attributes if (!_.isEmpty(charIDList) && !_.isEmpty(setting)) { if (deleteMode) { deleteAttributes(whisper, allAttrs, opts.silent); } else { delayedSetAttributes(whisper, charIDList, setting, errors, allAttrs, fillInAttrs, _.pick(opts, optsArray)); } } } return; }, registerEventHandlers = function () { on('chat:message', handleInput); }; return { CheckInstall: checkInstall, RegisterEventHandlers: registerEventHandlers }; }()); on('ready', function () { 'use strict'; chatSetAttr.CheckInstall(); chatSetAttr.RegisterEventHandlers(); });