import { ATTRIBUTE_TYPES } from "./constants.js";
export class EntitySheetHelper {
static getAttributeData(data) {
data.dtypes = ATTRIBUTE_TYPES;
// Determine attribute type.
for ( let attr of Object.values(data.data.attributes) ) {
if ( attr.dtype ) {
attr.isCheckbox = attr.dtype === "Boolean";
attr.isResource = attr.dtype === "Resource";
attr.isFormula = attr.dtype === "Formula";
}
}
// Initialize ungrouped attributes for later.
data.data.ungroupedAttributes = {};
// Build an array of sorted group keys.
const groups = data.data.groups || {};
let groupKeys = Object.keys(groups).sort((a, b) => {
let aSort = groups[a].label ?? a;
let bSort = groups[b].label ?? b;
return aSort.localeCompare(bSort);
});
// Iterate over the sorted groups to add their attributes.
for ( let key of groupKeys ) {
let group = data.data.attributes[key] || {};
// Initialize the attributes container for this group.
if ( !data.data.groups[key]['attributes'] ) data.data.groups[key]['attributes'] = {};
// Sort the attributes within the group, and then iterate over them.
Object.keys(group).sort((a, b) => a.localeCompare(b)).forEach(attr => {
// Avoid errors if this is an invalid group.
if ( typeof group[attr] != "object" || !group[attr]) return;
// For each attribute, determine whether it's a checkbox or resource, and then add it to the group's attributes list.
group[attr]['isCheckbox'] = group[attr]['dtype'] === 'Boolean';
group[attr]['isResource'] = group[attr]['dtype'] === 'Resource';
group[attr]['isFormula'] = group[attr]['dtype'] === 'Formula';
data.data.groups[key]['attributes'][attr] = group[attr];
});
}
// Sort the remaining attributes attributes.
Object.keys(data.data.attributes).filter(a => !groupKeys.includes(a)).sort((a, b) => a.localeCompare(b)).forEach(key => {
data.data.ungroupedAttributes[key] = data.data.attributes[key];
});
// Modify attributes on items.
if ( data.items ) {
data.items.forEach(item => {
// Iterate over attributes.
for ( let [k, v] of Object.entries(item.data.attributes) ) {
// Grouped attributes.
if ( !v.dtype ) {
for ( let [gk, gv] of Object.entries(v) ) {
if ( gv.dtype ) {
// Add label fallback.
if ( !gv.label ) gv.label = gk;
// Add formula bool.
if ( gv.dtype == "Formula" ) {
gv.isFormula = true;
}
else {
gv.isFormula = false;
}
}
}
}
// Ungrouped attributes.
else {
// Add label fallback.
if ( !v.label ) v.label = k;
// Add formula bool.
if ( v.dtype == "Formula" ) {
v.isFormula = true;
}
else {
v.isFormula = false;
}
}
}
});
}
}
/* -------------------------------------------- */
/** @override */
static onSubmit(event) {
// Closing the form/sheet will also trigger a submit, so only evaluate if this is an event.
if ( event.currentTarget ) {
// Exit early if this isn't a named attribute.
if ( event.currentTarget.tagName.toLowerCase() == 'input' && !event.currentTarget.hasAttribute('name')) {
return false;
}
let attr = false;
// If this is the attribute key, we need to make a note of it so that we can restore focus when its recreated.
const el = event.currentTarget;
if ( el.classList.contains("attribute-key") ) {
let val = el.value;
let oldVal = el.closest(".attribute").dataset.attribute;
let attrError = false;
// Prevent attributes that already exist as groups.
let groups = document.querySelectorAll('.group-key');
for ( let i = 0; i < groups.length; i++ ) {
if (groups[i].value == val) {
ui.notifications.error(game.i18n.localize("SIMPLE.NotifyAttrDuplicate") + ` (${val})`);
el.value = oldVal;
attrError = true;
break;
}
}
// Handle value and name replacement otherwise.
if ( !attrError ) {
oldVal = oldVal.includes('.') ? oldVal.split('.')[1] : oldVal;
attr = $(el).attr('name').replace(oldVal, val);
}
}
// Return the attribute key if set, or true to confirm the submission should be triggered.
return attr ? attr : true;
}
}
/* -------------------------------------------- */
/**
* Listen for click events on an attribute control to modify the composition of attributes in the sheet
* @param {MouseEvent} event The originating left click event
* @private
*/
static async onClickAttributeControl(event) {
event.preventDefault();
const a = event.currentTarget;
const action = a.dataset.action;
// Perform create and delete actions.
switch ( action ) {
case "create":
EntitySheetHelper.createAttribute(event, this);
break;
case "delete":
EntitySheetHelper.deleteAttribute(event, this);
break;
}
}
/**
* Listen for click events and modify attribute groups.
* @param {MouseEvent} event The originating left click event
*/
static async onClickAttributeGroupControl(event) {
event.preventDefault();
const a = event.currentTarget;
const action = a.dataset.action;
switch ( action ) {
case "create-group":
EntitySheetHelper.createAttributeGroup(event, this);
break;
case "delete-group":
EntitySheetHelper.deleteAttributeGroup(event, this);
break;
}
}
/* -------------------------------------------- */
/**
* Listen for the roll button on attributes.
* @param {MouseEvent} event The originating left click event
*/
static onAttributeRoll(event) {
event.preventDefault();
const button = event.currentTarget;
const label = button.closest(".attribute").querySelector(".attribute-label")?.value;
const chatLabel = label ?? button.parentElement.querySelector(".attribute-key").value;
const shorthand = game.settings.get("worldbuilding", "macroShorthand");
// Use the actor for rollData so that formulas are always in reference to the parent actor.
const rollData = this.actor.getRollData();
let formula = button.closest(".attribute").querySelector(".attribute-value")?.value;
// If there's a formula, attempt to roll it.
if ( formula ) {
// Get the machine safe version of the item name.
let replacement = null;
if ( formula.includes('@item.') && this.item ) {
let itemName = this.item.name.slugify({strict: true});
replacement = !!shorthand ? `@items.${itemName}.` : `@items.${itemName}.attributes.`;
formula = formula.replace('@item.', replacement);
}
formula = EntitySheetHelper.replaceData(formula, rollData, {missing: null});
// Replace `@item` shorthand with the item name and make the roll.
let r = new Roll(formula, rollData);
r.roll().toMessage({
user: game.user._id,
speaker: ChatMessage.getSpeaker({ actor: this.actor }),
flavor: `${chatLabel}`
});
}
}
/* -------------------------------------------- */
/**
* Return HTML for a new attribute to be applied to the form for submission.
*
* @param {Object} items Keyed object where each item has a "type" and "value" property.
* @param {string} index Numeric index or key of the new attribute.
* @param {string|boolean} group String key of the group, or false.
*
* @returns {string} Html string.
*/
static getAttributeHtml(items, index, group = false) {
// Initialize the HTML.
let result = '
';
// Iterate over the supplied keys and build their inputs (including whether or not they need a group key).
for (let [key, item] of Object.entries(items)) {
result = result + ``;
}
// Close the HTML and return.
return result + '
';
}
/* -------------------------------------------- */
/**
* Validate whether or not a group name can be used.
* @param {string} groupName Groupname to validate
* @returns {boolean}
*/
static validateGroup(groupName, entity) {
let groups = Object.keys(entity.object.data.data.groups);
let attributes = Object.keys(entity.object.data.data.attributes).filter(a => !groups.includes(a));
// Check for duplicate group keys.
if ( groups.includes(groupName) ) {
ui.notifications.error(game.i18n.localize("SIMPLE.NotifyGroupDuplicate") + ` (${groupName})`);
return false;
}
// Check for group keys that match attribute keys.
if ( attributes.includes(groupName) ) {
ui.notifications.error(game.i18n.localize("SIMPLE.NotifyGroupAttrDuplicate") + ` (${groupName})`);
return false;
}
// Check for whitespace or periods.
if ( groupName.match(/[\s|\.]/i) ) {
ui.notifications.error(game.i18n.localize("SIMPLE.NotifyGroupAlphanumeric"));
return false;
}
return true;
}
/* -------------------------------------------- */
/**
* Create new attributes.
* @param {MouseEvent} event The originating left click event
* @param {Object} app The form application object.
* @private
*/
static async createAttribute(event, app) {
const a = event.currentTarget;
const group = a.dataset.group;
let dtype = a.dataset.dtype;
const attrs = app.object.data.data.attributes;
const groups = app.object.data.data.groups;
const form = app.form;
// Determine the new attribute key for ungrouped attributes.
let objKeys = Object.keys(attrs).filter(k => !Object.keys(groups).includes(k));
let nk = Object.keys(attrs).length + 1;
let newValue = `attr${nk}`;
let newKey = document.createElement("div");
while ( objKeys.includes(newValue) ) {
++nk;
newValue = `attr${nk}`;
};
// Build options for construction HTML inputs.
let htmlItems = {
key: {
type: "text",
value: newValue
}
};
// Grouped attributes.
if ( group ) {
objKeys = attrs[group] ? Object.keys(attrs[group]) : [];
nk = objKeys.length + 1;
newValue = `attr${nk}`;
while ( objKeys.includes(newValue) ) {
++nk;
newValue = `attr${nk}`;
}
// Update the HTML options used to build the new input.
htmlItems.key.value = newValue;
htmlItems.group = {
type: "hidden",
value: group
};
htmlItems.dtype = {
type: "hidden",
value: dtype
};
}
// Ungrouped attributes.
else {
// Choose a default dtype based on the last attribute, fall back to "String".
if (!dtype) {
let lastAttr = document.querySelector('.attributes > .attributes-group .attribute:last-child .attribute-dtype')?.value;
dtype = lastAttr ? lastAttr : "String";
htmlItems.dtype = {
type: "hidden",
value: dtype
};
}
}
// Build the form elements used to create the new grouped attribute.
newKey.innerHTML = EntitySheetHelper.getAttributeHtml(htmlItems, nk, group);
// Append the form element and submit the form.
newKey = newKey.children[0];
form.appendChild(newKey);
await app._onSubmit(event);
}
/**
* Delete an attribute.
* @param {MouseEvent} event The originating left click event
* @param {Object} app The form application object.
* @private
*/
static async deleteAttribute(event, app) {
const a = event.currentTarget;
const li = a.closest(".attribute");
if ( li ) {
li.parentElement.removeChild(li);
await app._onSubmit(event);
}
}
/* -------------------------------------------- */
/**
* Create new attribute groups.
* @param {MouseEvent} event The originating left click event
* @param {Object} app The form application object.
* @private
*/
static async createAttributeGroup(event, app) {
const a = event.currentTarget;
const form = app.form;
let newValue = $(a).siblings('.group-prefix').val();
// Verify the new group key is valid, and use it to create the group.
if ( newValue.length > 0 && EntitySheetHelper.validateGroup(newValue, app) ) {
let newKey = document.createElement("div");
newKey.innerHTML = ``;
// Append the form element and submit the form.
newKey = newKey.children[0];
form.appendChild(newKey);
await app._onSubmit(event);
}
}
/**
* Delete an attribute group.
* @param {MouseEvent} event The originating left click event
* @param {Object} app The form application object.
* @private
*/
static async deleteAttributeGroup(event, app) {
const a = event.currentTarget;
let groupHeader = a.closest(".group-header");
let groupContainer = groupHeader.closest(".group");
let group = $(groupHeader).find('.group-key');
// Create a dialog to confirm group deletion.
new Dialog({
title: game.i18n.localize("SIMPLE.DeleteGroup"),
content: `${game.i18n.localize("SIMPLE.DeleteGroupContent")} ${group.val()}`,
buttons: {
confirm: {
icon: '',
label: game.i18n.localize("Yes"),
callback: async () => {
groupContainer.parentElement.removeChild(groupContainer);
await app._onSubmit(event);
}
},
cancel: {
icon: '',
label: game.i18n.localize("No"),
}
}
}).render(true);
}
/* -------------------------------------------- */
/**
* Update attributes when updating an actor object.
*
* @param {Object} formData Form data object to modify keys and values for.
* @returns {Object} updated formData object.
*/
static updateAttributes(formData, entity) {
let groupKeys = [];
// Handle the free-form attributes list
const formAttrs = expandObject(formData).data.attributes || {};
const attributes = Object.values(formAttrs).reduce((obj, v) => {
let attrs = [];
let group = null;
// Handle attribute keys for grouped attributes.
if ( !v["key"] ) {
attrs = Object.keys(v);
attrs.forEach(attrKey => {
group = v[attrKey]['group'];
groupKeys.push(group);
let attr = v[attrKey];
let k = v[attrKey]["key"] ? v[attrKey]["key"].trim() : attrKey.trim();
if ( /[\s\.]/.test(k) ) return ui.notifications.error("Attribute keys may not contain spaces or periods");
delete attr["key"];
// Add the new attribute if it's grouped, but we need to build the nested structure first.
if ( !obj[group] ) {
obj[group] = {};
}
obj[group][k] = attr;
});
}
// Handle attribute keys for ungrouped attributes.
else {
let k = v["key"].trim();
if ( /[\s\.]/.test(k) ) return ui.notifications.error("Attribute keys may not contain spaces or periods");
delete v["key"];
// Add the new attribute only if it's ungrouped.
if ( !group ) {
obj[k] = v;
}
}
return obj;
}, {});
// Remove attributes which are no longer used
for ( let k of Object.keys(entity.object.data.data.attributes) ) {
if ( !attributes.hasOwnProperty(k) ) attributes[`-=${k}`] = null;
}
// Remove grouped attributes which are no longer used.
for ( let group of groupKeys) {
if ( entity.object.data.data.attributes[group] ) {
for ( let k of Object.keys(entity.object.data.data.attributes[group]) ) {
if ( !attributes[group].hasOwnProperty(k) ) attributes[group][`-=${k}`] = null;
}
}
}
// Re-combine formData
formData = Object.entries(formData).filter(e => !e[0].startsWith("data.attributes")).reduce((obj, e) => {
obj[e[0]] = e[1];
return obj;
}, {_id: entity.object._id, "data.attributes": attributes});
return formData;
}
/**
* Update attribute groups when updating an actor object.
*
* @param {Object} formData Form data object to modify keys and values for.
* @returns {Object} updated formData object.
*/
static updateGroups(formData, entity) {
// Handle the free-form groups list
const formGroups = expandObject(formData).data.groups || {};
const groups = Object.values(formGroups).reduce((obj, v) => {
// If there are duplicate groups, collapse them.
if ( Array.isArray(v["key"]) ) {
v["key"] = v["key"][0];
}
// Trim and clean up.
let k = v["key"].trim();
if ( /[\s\.]/.test(k) ) return ui.notifications.error("Group keys may not contain spaces or periods");
delete v["key"];
obj[k] = v;
return obj;
}, {});
// Remove groups which are no longer used
for ( let k of Object.keys(entity.object.data.data.groups) ) {
if ( !groups.hasOwnProperty(k) ) groups[`-=${k}`] = null;
}
// Re-combine formData
formData = Object.entries(formData).filter(e => !e[0].startsWith("data.groups")).reduce((obj, e) => {
obj[e[0]] = e[1];
return obj;
}, {_id: entity.object._id, "data.groups": groups});
return formData;
}
/* -------------------------------------------- */
/**
* Replace referenced data attributes in the roll formula with the syntax `@attr` with the corresponding key from
* the provided `data` object. This is a temporary helper function that will be replaced with Roll.replaceFormulaData()
* in Foundry 0.7.1.
*
* @param {String} formula The original formula within which to replace.
* @param {Object} data Data object to use for value replacements.
* @param {Object} missing Value to use as missing replacements, such as {missing: "0"}.
* @return {String} The formula with attributes replaced with values.
*/
static replaceData(formula, data, {missing=null,depth=1}={}) {
// Exit early if the formula is invalid.
if ( typeof formula != "string" || depth < 1) {
return 0;
}
// Replace attributes with their numeric equivalents.
let dataRgx = new RegExp(/@([a-z.0-9_\-]+)/gi);
let rollFormula = formula.replace(dataRgx, (match, term) => {
let value = getProperty(data, term);
// If there was a value returned, trim and return it.
if ( value ) {
return String(value).trim();
}
// Otherwise, return either the missing replacement value, or the original @attr string for later replacement.
else {
return missing != null ? missing : `@${term}`;
}
});
return rollFormula;
}
}