Files
Foundry-VTT-Docker/resources/app/common/data/fields.mjs
2025-01-04 00:34:03 +01:00

2978 lines
97 KiB
JavaScript

/**
* This module contains data field classes which are used to define a data schema.
* A data field is responsible for cleaning, validation, and initialization of the value assigned to it.
* Each data field extends the [DataField]{@link DataField} class to implement logic specific to its
* contained data type.
* @module fields
*/
import {
ALL_DOCUMENT_TYPES,
BASE_DOCUMENT_TYPE,
DOCUMENT_OWNERSHIP_LEVELS,
FILE_CATEGORIES
} from "../constants.mjs";
import DataModel from "../abstract/data.mjs";
import {
isColorString,
isValidId,
isJSON,
hasFileExtension,
isBase64Data
} from "./validators.mjs";
import {deepClone, getType, isEmpty, isSubclass, mergeObject, parseUuid} from "../utils/helpers.mjs";
import {logCompatibilityWarning} from "../utils/logging.mjs";
import {DataModelValidationFailure} from "./validation-failure.mjs";
import SingletonEmbeddedCollection from "../abstract/singleton-collection.mjs";
import EmbeddedCollection from "../abstract/embedded-collection.mjs";
import EmbeddedCollectionDelta from "../abstract/embedded-collection-delta.mjs";
import {AsyncFunction} from "../utils/module.mjs";
/* ---------------------------------------- */
/* Abstract Data Field */
/* ---------------------------------------- */
/**
* @callback DataFieldValidator
* A Custom DataField validator function.
*
* A boolean return value indicates that the value is valid (true) or invalid (false) with certainty. With an explicit
* boolean return value no further validation functions will be evaluated.
*
* An undefined return indicates that the value may be valid but further validation functions should be performed,
* if defined.
*
* An Error may be thrown which provides a custom error message explaining the reason the value is invalid.
*
* @param {any} value The value provided for validation
* @param {DataFieldValidationOptions} options Validation options
* @returns {boolean|void}
* @throws {Error}
*/
/**
* @typedef {Object} DataFieldOptions
* @property {boolean} [required=false] Is this field required to be populated?
* @property {boolean} [nullable=false] Can this field have null values?
* @property {boolean} [gmOnly=false] Can this field only be modified by a gamemaster or assistant gamemaster?
* @property {Function|*} [initial] The initial value of a field, or a function which assigns that initial value.
* @property {string} [label] A localizable label displayed on forms which render this field.
* @property {string} [hint] Localizable help text displayed on forms which render this field.
* @property {DataFieldValidator} [validate] A custom data field validation function.
* @property {string} [validationError] A custom validation error string. When displayed will be prepended with the
* document name, field name, and candidate value. This error string is only
* used when the return type of the validate function is a boolean. If an Error
* is thrown in the validate function, the string message of that Error is used.
*/
/**
* @typedef {Object} DataFieldContext
* @property {string} [name] A field name to assign to the constructed field
* @property {DataField} [parent] Another data field which is a hierarchical parent of this one
*/
/**
* @typedef {object} DataFieldValidationOptions
* @property {boolean} [partial] Whether this is a partial schema validation, or a complete one.
* @property {boolean} [fallback] Whether to allow replacing invalid values with valid fallbacks.
* @property {object} [source] The full source object being evaluated.
* @property {boolean} [dropInvalidEmbedded] If true, invalid embedded documents will emit a warning and be placed in
* the invalidDocuments collection rather than causing the parent to be
* considered invalid.
*/
/**
* An abstract class that defines the base pattern for a data field within a data schema.
* @abstract
* @property {string} name The name of this data field within the schema that contains it.
* @mixes DataFieldOptions
*/
class DataField {
/**
* @param {DataFieldOptions} [options] Options which configure the behavior of the field
* @param {DataFieldContext} [context] Additional context which describes the field
*/
constructor(options={}, {name, parent}={}) {
this.name = name;
this.parent = parent;
this.options = options;
for ( let k in this.constructor._defaults ) {
this[k] = k in this.options ? this.options[k] : this.constructor._defaults[k];
}
}
/**
* The field name of this DataField instance.
* This is assigned by SchemaField#initialize.
* @internal
*/
name;
/**
* A reference to the parent schema to which this DataField belongs.
* This is assigned by SchemaField#initialize.
* @internal
*/
parent;
/**
* The initially provided options which configure the data field
* @type {DataFieldOptions}
*/
options;
/**
* Whether this field defines part of a Document/Embedded Document hierarchy.
* @type {boolean}
*/
static hierarchical = false;
/**
* Does this field type contain other fields in a recursive structure?
* Examples of recursive fields are SchemaField, ArrayField, or TypeDataField
* Examples of non-recursive fields are StringField, NumberField, or ObjectField
* @type {boolean}
*/
static recursive = false;
/**
* Default parameters for this field type
* @return {DataFieldOptions}
* @protected
*/
static get _defaults() {
return {
required: false,
nullable: false,
initial: undefined,
readonly: false,
gmOnly: false,
label: "",
hint: "",
validationError: "is not a valid value"
}
}
/**
* A dot-separated string representation of the field path within the parent schema.
* @type {string}
*/
get fieldPath() {
return [this.parent?.fieldPath, this.name].filterJoin(".");
}
/**
* Apply a function to this DataField which propagates through recursively to any contained data schema.
* @param {string|function} fn The function to apply
* @param {*} value The current value of this field
* @param {object} [options={}] Additional options passed to the applied function
* @returns {object} The results object
*/
apply(fn, value, options={}) {
if ( typeof fn === "string" ) fn = this[fn];
return fn.call(this, value, options);
}
/* -------------------------------------------- */
/* Field Cleaning */
/* -------------------------------------------- */
/**
* Coerce source data to ensure that it conforms to the correct data type for the field.
* Data coercion operations should be simple and synchronous as these are applied whenever a DataModel is constructed.
* For one-off cleaning of user-provided input the sanitize method should be used.
* @param {*} value The initial value
* @param {object} [options] Additional options for how the field is cleaned
* @param {boolean} [options.partial] Whether to perform partial cleaning?
* @param {object} [options.source] The root data model being cleaned
* @returns {*} The cast value
*/
clean(value, options={}) {
// Permit explicitly null values for nullable fields
if ( value === null ) {
if ( this.nullable ) return value;
value = undefined;
}
// Get an initial value for the field
if ( value === undefined ) return this.getInitialValue(options.source);
// Cast a provided value to the correct type
value = this._cast(value);
// Cleaning logic specific to the DataField.
return this._cleanType(value, options);
}
/* -------------------------------------------- */
/**
* Apply any cleaning logic specific to this DataField type.
* @param {*} value The appropriately coerced value.
* @param {object} [options] Additional options for how the field is cleaned.
* @returns {*} The cleaned value.
* @protected
*/
_cleanType(value, options) {
return value;
}
/* -------------------------------------------- */
/**
* Cast a non-default value to ensure it is the correct type for the field
* @param {*} value The provided non-default value
* @returns {*} The standardized value
* @protected
*/
_cast(value) {
throw new Error(`Subclasses of DataField must implement the _cast method`);
}
/* -------------------------------------------- */
/**
* Attempt to retrieve a valid initial value for the DataField.
* @param {object} data The source data object for which an initial value is required
* @returns {*} A valid initial value
* @throws An error if there is no valid initial value defined
*/
getInitialValue(data) {
return this.initial instanceof Function ? this.initial(data) : this.initial;
}
/* -------------------------------------------- */
/* Field Validation */
/* -------------------------------------------- */
/**
* Validate a candidate input for this field, ensuring it meets the field requirements.
* A validation failure can be provided as a raised Error (with a string message), by returning false, or by returning
* a DataModelValidationFailure instance.
* A validator which returns true denotes that the result is certainly valid and further validations are unnecessary.
* @param {*} value The initial value
* @param {DataFieldValidationOptions} [options={}] Options which affect validation behavior
* @returns {DataModelValidationFailure} Returns a DataModelValidationFailure if a validation failure
* occurred.
*/
validate(value, options={}) {
const validators = [this._validateSpecial, this._validateType];
if ( this.options.validate ) validators.push(this.options.validate);
try {
for ( const validator of validators ) {
const isValid = validator.call(this, value, options);
if ( isValid === true ) return undefined;
if ( isValid === false ) {
return new DataModelValidationFailure({
invalidValue: value,
message: this.validationError,
unresolved: true
});
}
if ( isValid instanceof DataModelValidationFailure ) return isValid;
}
} catch(err) {
return new DataModelValidationFailure({invalidValue: value, message: err.message, unresolved: true});
}
}
/* -------------------------------------------- */
/**
* Special validation rules which supersede regular field validation.
* This validator screens for certain values which are otherwise incompatible with this field like null or undefined.
* @param {*} value The candidate value
* @returns {boolean|void} A boolean to indicate with certainty whether the value is valid.
* Otherwise, return void.
* @throws May throw a specific error if the value is not valid
* @protected
*/
_validateSpecial(value) {
// Allow null values for explicitly nullable fields
if ( value === null ) {
if ( this.nullable ) return true;
else throw new Error("may not be null");
}
// Allow undefined if the field is not required
if ( value === undefined ) {
if ( this.required ) throw new Error("may not be undefined");
else return true;
}
}
/* -------------------------------------------- */
/**
* A default type-specific validator that can be overridden by child classes
* @param {*} value The candidate value
* @param {DataFieldValidationOptions} [options={}] Options which affect validation behavior
* @returns {boolean|DataModelValidationFailure|void} A boolean to indicate with certainty whether the value is
* valid, or specific DataModelValidationFailure information,
* otherwise void.
* @throws May throw a specific error if the value is not valid
* @protected
*/
_validateType(value, options={}) {}
/* -------------------------------------------- */
/**
* Certain fields may declare joint data validation criteria.
* This method will only be called if the field is designated as recursive.
* @param {object} data Candidate data for joint model validation
* @param {object} options Options which modify joint model validation
* @throws An error if joint model validation fails
* @internal
*/
_validateModel(data, options={}) {}
/* -------------------------------------------- */
/* Initialization and Serialization */
/* -------------------------------------------- */
/**
* Initialize the original source data into a mutable copy for the DataModel instance.
* @param {*} value The source value of the field
* @param {Object} model The DataModel instance that this field belongs to
* @param {object} [options] Initialization options
* @returns {*} An initialized copy of the source data
*/
initialize(value, model, options={}) {
return value;
}
/**
* Export the current value of the field into a serializable object.
* @param {*} value The initialized value of the field
* @returns {*} An exported representation of the field
*/
toObject(value) {
return value;
}
/**
* Recursively traverse a schema and retrieve a field specification by a given path
* @param {string[]} path The field path as an array of strings
* @internal
*/
_getField(path) {
return path.length ? undefined : this;
}
/* -------------------------------------------- */
/* Form Field Integration */
/* -------------------------------------------- */
/**
* Does this form field class have defined form support?
* @type {boolean}
*/
static get hasFormSupport() {
return this.prototype._toInput !== DataField.prototype._toInput;
}
/* -------------------------------------------- */
/**
* Render this DataField as an HTML element.
* @param {FormInputConfig} config Form element configuration parameters
* @throws {Error} An Error if this DataField subclass does not support input rendering
* @returns {HTMLElement|HTMLCollection} A rendered HTMLElement for the field
*/
toInput(config={}) {
const inputConfig = {name: this.fieldPath, ...config};
if ( inputConfig.input instanceof Function ) return config.input(this, inputConfig);
return this._toInput(inputConfig);
}
/* -------------------------------------------- */
/**
* Render this DataField as an HTML element.
* Subclasses should implement this method rather than the public toInput method which wraps it.
* @param {FormInputConfig} config Form element configuration parameters
* @throws {Error} An Error if this DataField subclass does not support input rendering
* @returns {HTMLElement|HTMLCollection} A rendered HTMLElement for the field
* @protected
*/
_toInput(config) {
throw new Error(`The ${this.constructor.name} class does not implement the _toInput method`);
}
/* -------------------------------------------- */
/**
* Render this DataField as a standardized form-group element.
* @param {FormGroupConfig} groupConfig Configuration options passed to the wrapping form-group
* @param {FormInputConfig} inputConfig Input element configuration options passed to DataField#toInput
* @returns {HTMLDivElement} The rendered form group element
*/
toFormGroup(groupConfig={}, inputConfig={}) {
if ( groupConfig.widget instanceof Function ) return groupConfig.widget(this, groupConfig, inputConfig);
groupConfig.label ??= this.label ?? this.fieldPath;
groupConfig.hint ??= this.hint;
groupConfig.input ??= this.toInput(inputConfig);
return foundry.applications.fields.createFormGroup(groupConfig);
}
/* -------------------------------------------- */
/* Active Effect Integration */
/* -------------------------------------------- */
/**
* Apply an ActiveEffectChange to this field.
* @param {*} value The field's current value.
* @param {DataModel} model The model instance.
* @param {EffectChangeData} change The change to apply.
* @returns {*} The updated value.
*/
applyChange(value, model, change) {
const delta = this._castChangeDelta(change.value);
switch ( change.mode ) {
case CONST.ACTIVE_EFFECT_MODES.ADD: return this._applyChangeAdd(value, delta, model, change);
case CONST.ACTIVE_EFFECT_MODES.MULTIPLY: return this._applyChangeMultiply(value, delta, model, change);
case CONST.ACTIVE_EFFECT_MODES.OVERRIDE: return this._applyChangeOverride(value, delta, model, change);
case CONST.ACTIVE_EFFECT_MODES.UPGRADE: return this._applyChangeUpgrade(value, delta, model, change);
case CONST.ACTIVE_EFFECT_MODES.DOWNGRADE: return this._applyChangeDowngrade(value, delta, model, change);
}
return this._applyChangeCustom(value, delta, model, change);
}
/* -------------------------------------------- */
/**
* Cast a change delta into an appropriate type to be applied to this field.
* @param {*} delta The change delta.
* @returns {*}
* @internal
*/
_castChangeDelta(delta) {
return this._cast(delta);
}
/* -------------------------------------------- */
/**
* Apply an ADD change to this field.
* @param {*} value The field's current value.
* @param {*} delta The change delta.
* @param {DataModel} model The model instance.
* @param {EffectChangeData} change The original change data.
* @returns {*} The updated value.
* @protected
*/
_applyChangeAdd(value, delta, model, change) {
return value + delta;
}
/* -------------------------------------------- */
/**
* Apply a MULTIPLY change to this field.
* @param {*} value The field's current value.
* @param {*} delta The change delta.
* @param {DataModel} model The model instance.
* @param {EffectChangeData} change The original change data.
* @returns {*} The updated value.
* @protected
*/
_applyChangeMultiply(value, delta, model, change) {}
/* -------------------------------------------- */
/**
* Apply an OVERRIDE change to this field.
* @param {*} value The field's current value.
* @param {*} delta The change delta.
* @param {DataModel} model The model instance.
* @param {EffectChangeData} change The original change data.
* @returns {*} The updated value.
* @protected
*/
_applyChangeOverride(value, delta, model, change) {
return delta;
}
/* -------------------------------------------- */
/**
* Apply an UPGRADE change to this field.
* @param {*} value The field's current value.
* @param {*} delta The change delta.
* @param {DataModel} model The model instance.
* @param {EffectChangeData} change The original change data.
* @returns {*} The updated value.
* @protected
*/
_applyChangeUpgrade(value, delta, model, change) {}
/* -------------------------------------------- */
/**
* Apply a DOWNGRADE change to this field.
* @param {*} value The field's current value.
* @param {*} delta The change delta.
* @param {DataModel} model The model instance.
* @param {EffectChangeData} change The original change data.
* @returns {*} The updated value.
* @protected
*/
_applyChangeDowngrade(value, delta, model, change) {}
/* -------------------------------------------- */
/**
* Apply a CUSTOM change to this field.
* @param {*} value The field's current value.
* @param {*} delta The change delta.
* @param {DataModel} model The model instance.
* @param {EffectChangeData} change The original change data.
* @returns {*} The updated value.
* @protected
*/
_applyChangeCustom(value, delta, model, change) {
const preHook = foundry.utils.getProperty(model, change.key);
Hooks.call("applyActiveEffect", model, change, value, delta, {});
const postHook = foundry.utils.getProperty(model, change.key);
if ( postHook !== preHook ) return postHook;
}
}
/* -------------------------------------------- */
/* Data Schema Field */
/* -------------------------------------------- */
/**
* A special class of {@link DataField} which defines a data schema.
*/
class SchemaField extends DataField {
/**
* @param {DataSchema} fields The contained field definitions
* @param {DataFieldOptions} [options] Options which configure the behavior of the field
* @param {DataFieldContext} [context] Additional context which describes the field
*/
constructor(fields, options, context={}) {
super(options, context);
this.fields = this._initialize(fields);
}
/* -------------------------------------------- */
/** @inheritdoc */
static get _defaults() {
return mergeObject(super._defaults, {
required: true,
nullable: false,
initial() { return this.clean({}); }
});
}
/** @override */
static recursive = true;
/* -------------------------------------------- */
/**
* The contained field definitions.
* @type {DataSchema}
*/
fields;
/**
* Any unknown keys encountered during the last cleaning.
* @type {string[]}
*/
unknownKeys;
/* -------------------------------------------- */
/**
* Initialize and validate the structure of the provided field definitions.
* @param {DataSchema} fields The provided field definitions
* @returns {DataSchema} The validated schema
* @protected
*/
_initialize(fields) {
if ( (typeof fields !== "object") ) {
throw new Error("A DataSchema must be an object with string keys and DataField values.");
}
fields = {...fields};
for ( const [name, field] of Object.entries(fields) ) {
if ( !(field instanceof DataField) ) {
throw new Error(`The "${name}" field is not an instance of the DataField class.`);
}
if ( field.parent !== undefined ) {
throw new Error(`The "${field.fieldPath}" field already belongs to some other parent and may not be reused.`);
}
field.name = name;
field.parent = this;
}
return fields;
}
/* -------------------------------------------- */
/* Schema Iteration */
/* -------------------------------------------- */
/**
* Iterate over a SchemaField by iterating over its fields.
* @type {Iterable<DataField>}
*/
*[Symbol.iterator]() {
for ( const field of Object.values(this.fields) ) {
yield field;
}
}
/**
* An array of field names which are present in the schema.
* @returns {string[]}
*/
keys() {
return Object.keys(this.fields);
}
/**
* An array of DataField instances which are present in the schema.
* @returns {DataField[]}
*/
values() {
return Object.values(this.fields);
}
/**
* An array of [name, DataField] tuples which define the schema.
* @returns {Array<[string, DataField]>}
*/
entries() {
return Object.entries(this.fields);
}
/**
* Test whether a certain field name belongs to this schema definition.
* @param {string} fieldName The field name
* @returns {boolean} Does the named field exist in this schema?
*/
has(fieldName) {
return fieldName in this.fields;
}
/**
* Get a DataField instance from the schema by name
* @param {string} fieldName The field name
* @returns {DataField} The DataField instance or undefined
*/
get(fieldName) {
return this.fields[fieldName];
}
/**
* Traverse the schema, obtaining the DataField definition for a particular field.
* @param {string[]|string} fieldName A field path like ["abilities", "strength"] or "abilities.strength"
* @returns {SchemaField|DataField} The corresponding DataField definition for that field, or undefined
*/
getField(fieldName) {
let path;
if ( typeof fieldName === "string" ) path = fieldName.split(".");
else if ( Array.isArray(fieldName) ) path = fieldName.slice();
else throw new Error("A field path must be an array of strings or a dot-delimited string");
return this._getField(path);
}
/** @override */
_getField(path) {
if ( !path.length ) return this;
const field = this.get(path.shift());
return field?._getField(path);
}
/* -------------------------------------------- */
/* Data Field Methods */
/* -------------------------------------------- */
/** @override */
_cast(value) {
return typeof value === "object" ? value : {};
}
/* -------------------------------------------- */
/** @inheritdoc */
_cleanType(data, options={}) {
options.source = options.source || data;
// Clean each field which belongs to the schema
for ( const [name, field] of this.entries() ) {
if ( !(name in data) && options.partial ) continue;
data[name] = field.clean(data[name], options);
}
// Delete any keys which do not
this.unknownKeys = [];
for ( const k of Object.keys(data) ) {
if ( this.has(k) ) continue;
this.unknownKeys.push(k);
delete data[k];
}
return data;
}
/* -------------------------------------------- */
/** @override */
initialize(value, model, options={}) {
if ( !value ) return value;
const data = {};
for ( let [name, field] of this.entries() ) {
const v = field.initialize(value[name], model, options);
// Readonly fields
if ( field.readonly ) {
Object.defineProperty(data, name, {value: v, writable: false});
}
// Getter fields
else if ( (typeof v === "function") && !v.prototype ) {
Object.defineProperty(data, name, {get: v, set() {}, configurable: true});
}
// Writable fields
else data[name] = v;
}
return data;
}
/* -------------------------------------------- */
/** @override */
_validateType(data, options={}) {
if ( !(data instanceof Object) ) throw new Error("must be an object");
options.source = options.source || data;
const schemaFailure = new DataModelValidationFailure();
for ( const [key, field] of this.entries() ) {
if ( options.partial && !(key in data) ) continue;
// Validate the field's current value
const value = data[key];
const failure = field.validate(value, options);
// Failure may be permitted if fallback replacement is allowed
if ( failure ) {
schemaFailure.fields[field.name] = failure;
// If the field internally applied fallback logic
if ( !failure.unresolved ) continue;
// If fallback is allowed at the schema level
if ( options.fallback ) {
const initial = field.getInitialValue(options.source);
if ( field.validate(initial, {source: options.source}) === undefined ) { // Ensure initial is valid
data[key] = initial;
failure.fallback = initial;
failure.unresolved = false;
}
else failure.unresolved = schemaFailure.unresolved = true;
}
// Otherwise the field-level failure is unresolved
else failure.unresolved = schemaFailure.unresolved = true;
}
}
if ( !isEmpty(schemaFailure.fields) ) return schemaFailure;
}
/* ---------------------------------------- */
/** @override */
_validateModel(changes, options={}) {
options.source = options.source || changes;
if ( !changes ) return;
for ( const [name, field] of this.entries() ) {
const change = changes[name]; // May be nullish
if ( change && field.constructor.recursive ) field._validateModel(change, options);
}
}
/* -------------------------------------------- */
/** @override */
toObject(value) {
if ( (value === undefined) || (value === null) ) return value;
const data = {};
for ( const [name, field] of this.entries() ) {
data[name] = field.toObject(value[name]);
}
return data;
}
/* -------------------------------------------- */
/** @override */
apply(fn, data={}, options={}) {
// Apply to this SchemaField
const thisFn = typeof fn === "string" ? this[fn] : fn;
thisFn?.call(this, data, options);
// Recursively apply to inner fields
const results = {};
for ( const [key, field] of this.entries() ) {
if ( options.partial && !(key in data) ) continue;
const r = field.apply(fn, data[key], options);
if ( !options.filter || !isEmpty(r) ) results[key] = r;
}
return results;
}
/* -------------------------------------------- */
/**
* Migrate this field's candidate source data.
* @param {object} sourceData Candidate source data of the root model
* @param {any} fieldData The value of this field within the source data
*/
migrateSource(sourceData, fieldData) {
for ( const [key, field] of this.entries() ) {
const canMigrate = field.migrateSource instanceof Function;
if ( canMigrate && fieldData[key] ) field.migrateSource(sourceData, fieldData[key]);
}
}
}
/* -------------------------------------------- */
/* Basic Field Types */
/* -------------------------------------------- */
/**
* A subclass of [DataField]{@link DataField} which deals with boolean-typed data.
*/
class BooleanField extends DataField {
/** @inheritdoc */
static get _defaults() {
return mergeObject(super._defaults, {
required: true,
nullable: false,
initial: false
});
}
/** @override */
_cast(value) {
if ( typeof value === "string" ) return value === "true";
if ( typeof value === "object" ) return false;
return Boolean(value);
}
/** @override */
_validateType(value) {
if (typeof value !== "boolean") throw new Error("must be a boolean");
}
/** @override */
_toInput(config) {
return foundry.applications.fields.createCheckboxInput(config);
}
/* -------------------------------------------- */
/* Active Effect Integration */
/* -------------------------------------------- */
/** @override */
_applyChangeAdd(value, delta, model, change) {
return value || delta;
}
/** @override */
_applyChangeMultiply(value, delta, model, change) {
return value && delta;
}
/** @override */
_applyChangeUpgrade(value, delta, model, change) {
return delta > value ? delta : value;
}
_applyChangeDowngrade(value, delta, model, change) {
return delta < value ? delta : value;
}
}
/* ---------------------------------------- */
/**
* @typedef {DataFieldOptions} NumberFieldOptions
* @property {number} [min] A minimum allowed value
* @property {number} [max] A maximum allowed value
* @property {number} [step] A permitted step size
* @property {boolean} [integer=false] Must the number be an integer?
* @property {number} [positive=false] Must the number be positive?
* @property {number[]|object|function} [choices] An array of values or an object of values/labels which represent
* allowed choices for the field. A function may be provided which dynamically
* returns the array of choices.
*/
/**
* A subclass of [DataField]{@link DataField} which deals with number-typed data.
*
* @property {number} min A minimum allowed value
* @property {number} max A maximum allowed value
* @property {number} step A permitted step size
* @property {boolean} integer=false Must the number be an integer?
* @property {number} positive=false Must the number be positive?
* @property {number[]|object|function} [choices] An array of values or an object of values/labels which represent
* allowed choices for the field. A function may be provided which dynamically
* returns the array of choices.
*/
class NumberField extends DataField {
/**
* @param {NumberFieldOptions} options Options which configure the behavior of the field
* @param {DataFieldContext} [context] Additional context which describes the field
*/
constructor(options={}, context={}) {
super(options, context);
// If choices are provided, the field should not be null by default
if ( this.choices ) {
this.nullable = options.nullable ?? false;
}
if ( Number.isFinite(this.min) && Number.isFinite(this.max) && (this.min > this.max) ) {
throw new Error("NumberField minimum constraint cannot exceed its maximum constraint");
}
}
/** @inheritdoc */
static get _defaults() {
return mergeObject(super._defaults, {
initial: null,
nullable: true,
min: undefined,
max: undefined,
step: undefined,
integer: false,
positive: false,
choices: undefined
});
}
/** @override */
_cast(value) {
return Number(value);
}
/** @inheritdoc */
_cleanType(value, options) {
value = super._cleanType(value, options);
if ( typeof value !== "number" ) return value;
if ( this.integer ) value = Math.round(value);
if ( Number.isFinite(this.min) ) value = Math.max(value, this.min);
if ( Number.isFinite(this.max) ) value = Math.min(value, this.max);
if ( Number.isFinite(this.step) ) value = value.toNearest(this.step);
return value;
}
/** @override */
_validateType(value) {
if ( typeof value !== "number" ) throw new Error("must be a number");
if ( this.positive && (value <= 0) ) throw new Error("must be a positive number");
if ( Number.isFinite(this.min) && (value < this.min) ) throw new Error(`must be at least ${this.min}`);
if ( Number.isFinite(this.max) && (value > this.max) ) throw new Error(`must be at most ${this.max}`);
if ( Number.isFinite(this.step) && (value.toNearest(this.step) !== value) ) {
throw new Error(`must be an increment of ${this.step}`);
}
if ( this.choices && !this.#isValidChoice(value) ) throw new Error(`${value} is not a valid choice`);
if ( this.integer ) {
if ( !Number.isInteger(value) ) throw new Error("must be an integer");
}
else if ( !Number.isFinite(value) ) throw new Error("must be a finite number");
}
/**
* Test whether a provided value is a valid choice from the allowed choice set
* @param {number} value The provided value
* @returns {boolean} Is the choice valid?
*/
#isValidChoice(value) {
let choices = this.choices;
if ( choices instanceof Function ) choices = choices();
if ( choices instanceof Array ) return choices.includes(value);
return String(value) in choices;
}
/* -------------------------------------------- */
/* Form Field Integration */
/* -------------------------------------------- */
/** @override */
_toInput(config) {
config.min ??= this.min;
config.max ??= this.max;
config.step ??= this.step;
if ( config.value === undefined ) config.value = this.getInitialValue({});
if ( this.integer ) {
if ( Number.isNumeric(config.value) ) config.value = Math.round(config.value);
config.step ??= 1;
}
if ( this.positive && Number.isFinite(config.step) ) config.min ??= config.step;
// Number Select
config.choices ??= this.choices;
if ( config.choices && !config.options ) {
config.options = StringField._getChoices(config);
delete config.valueAttr;
delete config.labelAttr;
config.dataset ||= {};
config.dataset.dtype = "Number";
}
if ( config.options ) return foundry.applications.fields.createSelectInput(config);
// Range Slider
if ( ["min", "max", "step"].every(k => config[k] !== undefined) && (config.type !== "number") ) {
return foundry.applications.elements.HTMLRangePickerElement.create(config);
}
// Number Input
return foundry.applications.fields.createNumberInput(config);
}
/* -------------------------------------------- */
/* Active Effect Integration */
/* -------------------------------------------- */
/** @override */
_applyChangeMultiply(value, delta, model, change) {
return value * delta;
}
/** @override */
_applyChangeUpgrade(value, delta, model, change) {
return delta > value ? delta : value;
}
/** @override */
_applyChangeDowngrade(value, delta, model, change) {
return delta < value ? delta : value;
}
}
/* ---------------------------------------- */
/**
* @typedef {Object} StringFieldParams
* @property {boolean} [blank=true] Is the string allowed to be blank (empty)?
* @property {boolean} [trim=true] Should any provided string be trimmed as part of cleaning?
* @property {string[]|object|function} [choices] An array of values or an object of values/labels which represent
* allowed choices for the field. A function may be provided which dynamically
* returns the array of choices.
* @property {boolean} [textSearch=false] Is this string field a target for text search?
* @typedef {DataFieldOptions&StringFieldParams} StringFieldOptions
*/
/**
* A subclass of {@link DataField} which deals with string-typed data.
*/
class StringField extends DataField {
/**
* @param {StringFieldOptions} [options] Options which configure the behavior of the field
* @param {DataFieldContext} [context] Additional context which describes the field
*/
constructor(options={}, context={}) {
super(options, context);
// If choices are provided, the field should not be null or blank by default
if ( this.choices ) {
this.nullable = options.nullable ?? false;
this.blank = options.blank ?? false;
}
}
/** @inheritdoc */
static get _defaults() {
return mergeObject(super._defaults, {
blank: true,
trim: true,
nullable: false,
initial() {
// The initial value depends on the field configuration
if ( !this.required ) return undefined;
else if ( this.blank ) return "";
else if ( this.nullable ) return null;
return undefined;
},
choices: undefined,
textSearch: false
});
}
/**
* Is the string allowed to be blank (empty)?
* @type {boolean}
*/
blank = this.blank;
/**
* Should any provided string be trimmed as part of cleaning?
* @type {boolean}
*/
trim = this.trim;
/**
* An array of values or an object of values/labels which represent
* allowed choices for the field. A function may be provided which dynamically
* returns the array of choices.
* @type {string[]|object|function}
*/
choices = this.choices;
/**
* Is this string field a target for text search?
* @type {boolean}
*/
textSearch = this.textSearch;
/** @inheritdoc */
clean(value, options) {
if ( (typeof value === "string") && this.trim ) value = value.trim(); // Trim input strings
if ( value === "" ) { // Permit empty strings for blank fields
if ( this.blank ) return value;
value = undefined;
}
return super.clean(value, options);
}
/** @override */
_cast(value) {
return String(value);
}
/** @inheritdoc */
_validateSpecial(value) {
if ( value === "" ) {
if ( this.blank ) return true;
else throw new Error("may not be a blank string");
}
return super._validateSpecial(value);
}
/** @override */
_validateType(value) {
if ( typeof value !== "string" ) throw new Error("must be a string");
else if ( this.choices ) {
if ( this._isValidChoice(value) ) return true;
else throw new Error(`${value} is not a valid choice`);
}
}
/**
* Test whether a provided value is a valid choice from the allowed choice set
* @param {string} value The provided value
* @returns {boolean} Is the choice valid?
* @protected
*/
_isValidChoice(value) {
let choices = this.choices;
if ( choices instanceof Function ) choices = choices();
if ( choices instanceof Array ) return choices.includes(value);
return String(value) in choices;
}
/* -------------------------------------------- */
/* Form Field Integration */
/* -------------------------------------------- */
/**
* Get a record of eligible choices for the field.
* @param {object} [options]
* @param {Record<any, any>|Array<any>} options.choices
* @param {string} [options.labelAttr="label"] The property in the choice object values to use as the option label.
* @param {string} [options.valueAttr]
* @param {boolean} [options.localize=false] Pass each label through string localization?
* @returns {FormSelectOption[]}
* @internal
*/
static _getChoices({choices, labelAttr="label", valueAttr, localize=false}={}) {
if ( choices instanceof Function ) choices = choices();
if ( typeof choices === "object" ) {
choices = Object.entries(choices).reduce((arr, [value, label]) => {
if ( typeof label !== "string" ) {
if ( valueAttr && (valueAttr in label) ) value = label[valueAttr];
label = label[labelAttr] ?? "undefined";
}
if ( localize ) label = game.i18n.localize(label);
arr.push({value, label});
return arr;
}, [])
}
return choices;
}
/* -------------------------------------------- */
/** @override */
_toInput(config) {
if ( config.value === undefined ) config.value = this.getInitialValue({});
config.choices ??= this.choices;
if ( config.choices && !config.options ) {
config.options = StringField._getChoices(config);
delete config.choices;
delete config.valueAttr;
delete config.labelAttr;
if ( this.blank || !this.required ) config.blank ??= "";
}
if ( config.options ) return foundry.applications.fields.createSelectInput(config);
return foundry.applications.fields.createTextInput(config);
}
}
/* ---------------------------------------- */
/**
* A subclass of [DataField]{@link DataField} which deals with object-typed data.
*/
class ObjectField extends DataField {
/** @inheritdoc */
static get _defaults() {
return mergeObject(super._defaults, {
required: true,
nullable: false
});
}
/* -------------------------------------------- */
/** @override */
getInitialValue(data) {
const initial = super.getInitialValue(data);
if ( initial ) return initial; // Explicit initial value defined by subclass
if ( !this.required ) return undefined; // The ObjectField may be undefined
if ( this.nullable ) return null; // The ObjectField may be null
return {}; // Otherwise an empty object
}
/** @override */
_cast(value) {
return getType(value) === "Object" ? value : {};
}
/** @override */
initialize(value, model, options={}) {
if ( !value ) return value;
return deepClone(value);
}
/** @override */
toObject(value) {
return deepClone(value);
}
/** @override */
_validateType(value, options={}) {
if ( getType(value) !== "Object" ) throw new Error("must be an object");
}
}
/* -------------------------------------------- */
/**
* @typedef {DataFieldOptions} ArrayFieldOptions
* @property {number} [min] The minimum number of elements.
* @property {number} [max] The maximum number of elements.
*/
/**
* A subclass of [DataField]{@link DataField} which deals with array-typed data.
* @property {number} min The minimum number of elements.
* @property {number} max The maximum number of elements.
*/
class ArrayField extends DataField {
/**
* @param {DataField} element A DataField instance which defines the type of element contained in the Array
* @param {ArrayFieldOptions} [options] Options which configure the behavior of the field
* @param {DataFieldContext} [context] Additional context which describes the field
*/
constructor(element, options={}, context={}) {
super(options, context);
/**
* The data type of each element in this array
* @type {DataField}
*/
this.element = this.constructor._validateElementType(element);
if ( this.min > this.max ) throw new Error("ArrayField minimum length cannot exceed maximum length");
}
/** @inheritdoc */
static get _defaults() {
return mergeObject(super._defaults, {
required: true,
nullable: false,
empty: true,
exact: undefined,
min: 0,
max: Infinity,
initial: () => []
});
}
/** @override */
static recursive = true;
/* ---------------------------------------- */
/**
* Validate the contained element type of the ArrayField
* @param {*} element The type of Array element
* @returns {*} The validated element type
* @throws An error if the element is not a valid type
* @protected
*/
static _validateElementType(element) {
if ( !(element instanceof DataField) ) {
throw new Error(`${this.name} must have a DataField as its contained element`);
}
return element;
}
/* ---------------------------------------- */
/** @override */
_validateModel(changes, options) {
if ( !this.element.constructor.recursive ) return;
for ( const element of changes ) {
this.element._validateModel(element, options);
}
}
/* ---------------------------------------- */
/** @override */
_cast(value) {
const t = getType(value);
if ( t === "Object" ) {
const arr = [];
for ( const [k, v] of Object.entries(value) ) {
const i = Number(k);
if ( Number.isInteger(i) && (i >= 0) ) arr[i] = v;
}
return arr;
}
else if ( t === "Set" ) return Array.from(value);
return value instanceof Array ? value : [value];
}
/** @override */
_cleanType(value, options) {
// Force partial as false for array cleaning. Arrays are updated by replacing the entire array, so partial data
// must be initialized.
return value.map(v => this.element.clean(v, { ...options, partial: false }));
}
/** @override */
_validateType(value, options={}) {
if ( !(value instanceof Array) ) throw new Error("must be an Array");
if ( value.length < this.min ) throw new Error(`cannot have fewer than ${this.min} elements`);
if ( value.length > this.max ) throw new Error(`cannot have more than ${this.max} elements`);
return this._validateElements(value, options);
}
/**
* Validate every element of the ArrayField
* @param {Array} value The array to validate
* @param {DataFieldValidationOptions} options Validation options
* @returns {DataModelValidationFailure|void} A validation failure if any of the elements failed validation,
* otherwise void.
* @protected
*/
_validateElements(value, options) {
const arrayFailure = new DataModelValidationFailure();
for ( let i=0; i<value.length; i++ ) {
// Force partial as false for array validation. Arrays are updated by replacing the entire array, so there cannot
// be partial data in the elements.
const failure = this._validateElement(value[i], { ...options, partial: false });
if ( failure ) {
arrayFailure.elements.push({id: i, failure});
arrayFailure.unresolved ||= failure.unresolved;
}
}
if ( arrayFailure.elements.length ) return arrayFailure;
}
/**
* Validate a single element of the ArrayField.
* @param {*} value The value of the array element
* @param {DataFieldValidationOptions} options Validation options
* @returns {DataModelValidationFailure} A validation failure if the element failed validation
* @protected
*/
_validateElement(value, options) {
return this.element.validate(value, options);
}
/** @override */
initialize(value, model, options={}) {
if ( !value ) return value;
return value.map(v => this.element.initialize(v, model, options));
}
/** @override */
toObject(value) {
if ( !value ) return value;
return value.map(v => this.element.toObject(v));
}
/** @override */
apply(fn, value=[], options={}) {
// Apply to this ArrayField
const thisFn = typeof fn === "string" ? this[fn] : fn;
thisFn?.call(this, value, options);
// Recursively apply to array elements
const results = [];
if ( !value.length && options.initializeArrays ) value = [undefined];
for ( const v of value ) {
const r = this.element.apply(fn, v, options);
if ( !options.filter || !isEmpty(r) ) results.push(r);
}
return results;
}
/** @override */
_getField(path) {
if ( !path.length ) return this;
if ( path[0] === "element" ) path.shift();
return this.element._getField(path);
}
/**
* Migrate this field's candidate source data.
* @param {object} sourceData Candidate source data of the root model
* @param {any} fieldData The value of this field within the source data
*/
migrateSource(sourceData, fieldData) {
const canMigrate = this.element.migrateSource instanceof Function;
if ( canMigrate && (fieldData instanceof Array) ) {
for ( const entry of fieldData ) this.element.migrateSource(sourceData, entry);
}
}
/* -------------------------------------------- */
/* Active Effect Integration */
/* -------------------------------------------- */
/** @override */
_castChangeDelta(raw) {
let delta;
try {
delta = JSON.parse(raw);
delta = Array.isArray(delta) ? delta : [delta];
} catch {
delta = [raw];
}
return delta.map(value => this.element._castChangeDelta(value));
}
/** @override */
_applyChangeAdd(value, delta, model, change) {
value.push(...delta);
return value;
}
}
/* -------------------------------------------- */
/* Specialized Field Types */
/* -------------------------------------------- */
/**
* A subclass of [ArrayField]{@link ArrayField} which supports a set of contained elements.
* Elements in this set are treated as fungible and may be represented in any order or discarded if invalid.
*/
class SetField extends ArrayField {
/** @override */
_validateElements(value, options) {
const setFailure = new DataModelValidationFailure();
for ( let i=value.length-1; i>=0; i-- ) { // iterate backwards so we can splice as we go
const failure = this._validateElement(value[i], options);
if ( failure ) {
setFailure.elements.unshift({id: i, failure});
// The failure may have been internally resolved by fallback logic
if ( !failure.unresolved && failure.fallback ) continue;
// If fallback is allowed, remove invalid elements from the set
if ( options.fallback ) {
value.splice(i, 1);
failure.dropped = true;
}
// Otherwise the set failure is unresolved
else setFailure.unresolved = true;
}
}
// Return a record of any failed set elements
if ( setFailure.elements.length ) {
if ( options.fallback && !setFailure.unresolved ) setFailure.fallback = value;
return setFailure;
}
}
/** @override */
initialize(value, model, options={}) {
if ( !value ) return value;
return new Set(super.initialize(value, model, options));
}
/** @override */
toObject(value) {
if ( !value ) return value;
return Array.from(value).map(v => this.element.toObject(v));
}
/* -------------------------------------------- */
/* Form Field Integration */
/* -------------------------------------------- */
/** @override */
_toInput(config) {
const e = this.element;
// Document UUIDs
if ( e instanceof DocumentUUIDField ) {
Object.assign(config, {type: e.type, single: false});
return foundry.applications.elements.HTMLDocumentTagsElement.create(config);
}
// Multi-Select Input
if ( e.choices && !config.options ) {
config.options = StringField._getChoices({choices: e.choices, ...config});
}
if ( config.options ) return foundry.applications.fields.createMultiSelectInput(config);
// Arbitrary String Tags
if ( e instanceof StringField ) return foundry.applications.elements.HTMLStringTagsElement.create(config);
throw new Error(`SetField#toInput is not supported for a ${e.constructor.name} element type`);
}
/* -------------------------------------------- */
/* Active Effect Integration */
/* -------------------------------------------- */
/** @inheritDoc */
_castChangeDelta(raw) {
return new Set(super._castChangeDelta(raw));
}
/** @override */
_applyChangeAdd(value, delta, model, change) {
for ( const element of delta ) value.add(element);
return value;
}
}
/* ---------------------------------------- */
/**
* A subclass of [ObjectField]{@link ObjectField} which embeds some other DataModel definition as an inner object.
*/
class EmbeddedDataField extends SchemaField {
/**
* @param {typeof DataModel} model The class of DataModel which should be embedded in this field
* @param {DataFieldOptions} [options] Options which configure the behavior of the field
* @param {DataFieldContext} [context] Additional context which describes the field
*/
constructor(model, options={}, context={}) {
if ( !isSubclass(model, DataModel) ) {
throw new Error("An EmbeddedDataField must specify a DataModel class as its type");
}
// Create an independent copy of the model schema
const fields = model.defineSchema();
super(fields, options, context);
/**
* The base DataModel definition which is contained in this field.
* @type {typeof DataModel}
*/
this.model = model;
}
/** @inheritdoc */
clean(value, options) {
return super.clean(value, {...options, source: value});
}
/** @inheritdoc */
validate(value, options) {
return super.validate(value, {...options, source: value});
}
/** @override */
initialize(value, model, options={}) {
if ( !value ) return value;
const m = new this.model(value, {parent: model, ...options});
Object.defineProperty(m, "schema", {value: this});
return m;
}
/** @override */
toObject(value) {
if ( !value ) return value;
return value.toObject(false);
}
/** @override */
migrateSource(sourceData, fieldData) {
if ( fieldData ) this.model.migrateDataSafe(fieldData);
}
/** @override */
_validateModel(changes, options) {
this.model.validateJoint(changes);
}
}
/* ---------------------------------------- */
/**
* A subclass of [ArrayField]{@link ArrayField} which supports an embedded Document collection.
* Invalid elements will be dropped from the collection during validation rather than failing for the field entirely.
*/
class EmbeddedCollectionField extends ArrayField {
/**
* @param {typeof foundry.abstract.Document} element The type of Document which belongs to this embedded collection
* @param {DataFieldOptions} [options] Options which configure the behavior of the field
* @param {DataFieldContext} [context] Additional context which describes the field
*/
constructor(element, options={}, context={}) {
super(element, options, context);
this.readonly = true; // Embedded collections are always immutable
}
/** @override */
static _validateElementType(element) {
if ( isSubclass(element, foundry.abstract.Document) ) return element;
throw new Error("An EmbeddedCollectionField must specify a Document subclass as its type");
}
/**
* The Collection implementation to use when initializing the collection.
* @type {typeof EmbeddedCollection}
*/
static get implementation() {
return EmbeddedCollection;
}
/** @override */
static hierarchical = true;
/**
* A reference to the DataModel subclass of the embedded document element
* @type {typeof foundry.abstract.Document}
*/
get model() {
return this.element.implementation;
}
/**
* The DataSchema of the contained Document model.
* @type {SchemaField}
*/
get schema() {
return this.model.schema;
}
/** @inheritDoc */
_cast(value) {
if ( getType(value) !== "Map" ) return super._cast(value);
const arr = [];
for ( const [id, v] of value.entries() ) {
if ( !("_id" in v) ) v._id = id;
arr.push(v);
}
return super._cast(arr);
}
/** @override */
_cleanType(value, options) {
return value.map(v => this.schema.clean(v, {...options, source: v}));
}
/** @override */
_validateElements(value, options) {
const collectionFailure = new DataModelValidationFailure();
for ( const v of value ) {
const failure = this.schema.validate(v, {...options, source: v});
if ( failure && !options.dropInvalidEmbedded ) {
collectionFailure.elements.push({id: v._id, name: v.name, failure});
collectionFailure.unresolved ||= failure.unresolved;
}
}
if ( collectionFailure.elements.length ) return collectionFailure;
}
/** @override */
initialize(value, model, options={}) {
const collection = model.collections[this.name];
collection.initialize(options);
return collection;
}
/** @override */
toObject(value) {
return value.toObject(false);
}
/** @override */
apply(fn, value=[], options={}) {
// Apply to this EmbeddedCollectionField
const thisFn = typeof fn === "string" ? this[fn] : fn;
thisFn?.call(this, value, options);
// Recursively apply to inner fields
const results = [];
if ( !value.length && options.initializeArrays ) value = [undefined];
for ( const v of value ) {
const r = this.schema.apply(fn, v, options);
if ( !options.filter || !isEmpty(r) ) results.push(r);
}
return results;
}
/**
* Migrate this field's candidate source data.
* @param {object} sourceData Candidate source data of the root model
* @param {any} fieldData The value of this field within the source data
*/
migrateSource(sourceData, fieldData) {
if ( fieldData instanceof Array ) {
for ( const entry of fieldData ) this.model.migrateDataSafe(entry);
}
}
/* -------------------------------------------- */
/* Embedded Document Operations */
/* -------------------------------------------- */
/**
* Return the embedded document(s) as a Collection.
* @param {foundry.abstract.Document} parent The parent document.
* @returns {DocumentCollection}
*/
getCollection(parent) {
return parent[this.name];
}
}
/* -------------------------------------------- */
/**
* A subclass of {@link EmbeddedCollectionField} which manages a collection of delta objects relative to another
* collection.
*/
class EmbeddedCollectionDeltaField extends EmbeddedCollectionField {
/** @override */
static get implementation() {
return EmbeddedCollectionDelta;
}
/** @override */
_cleanType(value, options) {
return value.map(v => {
if ( v._tombstone ) return foundry.data.TombstoneData.schema.clean(v, {...options, source: v});
return this.schema.clean(v, {...options, source: v});
});
}
/** @override */
_validateElements(value, options) {
const collectionFailure = new DataModelValidationFailure();
for ( const v of value ) {
const validationOptions = {...options, source: v};
const failure = v._tombstone
? foundry.data.TombstoneData.schema.validate(v, validationOptions)
: this.schema.validate(v, validationOptions);
if ( failure && !options.dropInvalidEmbedded ) {
collectionFailure.elements.push({id: v._id, name: v.name, failure});
collectionFailure.unresolved ||= failure.unresolved;
}
}
if ( collectionFailure.elements.length ) return collectionFailure;
}
}
/* -------------------------------------------- */
/**
* A subclass of {@link EmbeddedDataField} which supports a single embedded Document.
*/
class EmbeddedDocumentField extends EmbeddedDataField {
/**
* @param {typeof foundry.abstract.Document} model The type of Document which is embedded.
* @param {DataFieldOptions} [options] Options which configure the behavior of the field.
* @param {DataFieldContext} [context] Additional context which describes the field
*/
constructor(model, options={}, context={}) {
if ( !isSubclass(model, foundry.abstract.Document) ) {
throw new Error("An EmbeddedDocumentField must specify a Document subclass as its type.");
}
super(model.implementation, options, context);
}
/** @inheritdoc */
static get _defaults() {
return mergeObject(super._defaults, {
nullable: true
});
}
/** @override */
static hierarchical = true;
/** @override */
initialize(value, model, options={}) {
if ( !value ) return value;
if ( model[this.name] ) {
model[this.name]._initialize(options);
return model[this.name];
}
const m = new this.model(value, {...options, parent: model, parentCollection: this.name});
Object.defineProperty(m, "schema", {value: this});
return m;
}
/* -------------------------------------------- */
/* Embedded Document Operations */
/* -------------------------------------------- */
/**
* Return the embedded document(s) as a Collection.
* @param {Document} parent The parent document.
* @returns {Collection<Document>}
*/
getCollection(parent) {
const collection = new SingletonEmbeddedCollection(this.name, parent, []);
const doc = parent[this.name];
if ( !doc ) return collection;
collection.set(doc.id, doc);
return collection;
}
}
/* -------------------------------------------- */
/* Special Field Types */
/* -------------------------------------------- */
/**
* A subclass of [StringField]{@link StringField} which provides the primary _id for a Document.
* The field may be initially null, but it must be non-null when it is saved to the database.
*/
class DocumentIdField extends StringField {
/** @inheritdoc */
static get _defaults() {
return mergeObject(super._defaults, {
required: true,
blank: false,
nullable: true,
initial: null,
readonly: true,
validationError: "is not a valid Document ID string"
});
}
/** @override */
_cast(value) {
if ( value instanceof foundry.abstract.Document ) return value._id;
else return String(value);
}
/** @override */
_validateType(value) {
if ( !isValidId(value) ) throw new Error("must be a valid 16-character alphanumeric ID");
}
}
/* ---------------------------------------- */
/**
* @typedef {Object} DocumentUUIDFieldOptions
* @property {string} [type] A specific document type in CONST.ALL_DOCUMENT_TYPES required by this field
* @property {boolean} [embedded] Does this field require (or prohibit) embedded documents?
*/
/**
* A subclass of {@link StringField} which supports referencing some other Document by its UUID.
* This field may not be blank, but may be null to indicate that no UUID is referenced.
*/
class DocumentUUIDField extends StringField {
/**
* @param {StringFieldOptions & DocumentUUIDFieldOptions} [options] Options which configure the behavior of the field
* @param {DataFieldContext} [context] Additional context which describes the field
*/
constructor(options, context) {
super(options, context);
}
/** @inheritdoc */
static get _defaults() {
return Object.assign(super._defaults, {
required: true,
blank: false,
nullable: true,
initial: null,
type: undefined,
embedded: undefined
});
}
/** @override */
_validateType(value) {
const p = parseUuid(value);
if ( this.type ) {
if ( p.type !== this.type ) throw new Error(`Invalid document type "${p.type}" which must be a "${this.type}"`);
}
else if ( p.type && !ALL_DOCUMENT_TYPES.includes(p.type) ) throw new Error(`Invalid document type "${p.type}"`);
if ( (this.embedded === true) && !p.embedded.length ) throw new Error("must be an embedded document");
if ( (this.embedded === false) && p.embedded.length ) throw new Error("may not be an embedded document");
if ( !isValidId(p.documentId) ) throw new Error(`Invalid document ID "${p.documentId}"`);
}
/* -------------------------------------------- */
/* Form Field Integration */
/* -------------------------------------------- */
/** @override */
_toInput(config) {
Object.assign(config, {type: this.type, single: true});
return foundry.applications.elements.HTMLDocumentTagsElement.create(config);
}
}
/* ---------------------------------------- */
/**
* A special class of [StringField]{@link StringField} field which references another DataModel by its id.
* This field may also be null to indicate that no foreign model is linked.
*/
class ForeignDocumentField extends DocumentIdField {
/**
* @param {typeof foundry.abstract.Document} model The foreign DataModel class definition which this field links to
* @param {StringFieldOptions} [options] Options which configure the behavior of the field
* @param {DataFieldContext} [context] Additional context which describes the field
*/
constructor(model, options={}, context={}) {
super(options, context);
if ( !isSubclass(model, DataModel) ) {
throw new Error("A ForeignDocumentField must specify a DataModel subclass as its type");
}
/**
* A reference to the model class which is stored in this field
* @type {typeof foundry.abstract.Document}
*/
this.model = model;
}
/** @inheritdoc */
static get _defaults() {
return mergeObject(super._defaults, {
nullable: true,
readonly: false,
idOnly: false
});
}
/** @override */
_cast(value) {
if ( typeof value === "string" ) return value;
if ( (value instanceof this.model) ) return value._id;
throw new Error(`The value provided to a ForeignDocumentField must be a ${this.model.name} instance.`);
}
/** @inheritdoc */
initialize(value, model, options={}) {
if ( this.idOnly ) return value;
if ( model?.pack && !foundry.utils.isSubclass(this.model, foundry.documents.BaseFolder) ) return null;
if ( !game.collections ) return value; // server-side
return () => this.model?.get(value, {pack: model?.pack, ...options}) ?? null;
}
/** @inheritdoc */
toObject(value) {
return value?._id ?? value
}
/* -------------------------------------------- */
/* Form Field Integration */
/* -------------------------------------------- */
/** @override */
_toInput(config) {
// Prepare array of visible options
const collection = game.collections.get(this.model.documentName);
const current = collection.get(config.value);
let hasCurrent = false;
const options = collection.reduce((arr, doc) => {
if ( !doc.visible ) return arr;
if ( doc === current ) hasCurrent = true;
arr.push({value: doc.id, label: doc.name});
return arr;
}, []);
if ( current && !hasCurrent ) options.unshift({value: config.value, label: current.name});
Object.assign(config, {options});
// Allow blank
if ( !this.required || this.nullable ) config.blank = "";
// Create select input
return foundry.applications.fields.createSelectInput(config);
}
}
/* -------------------------------------------- */
/**
* A special [StringField]{@link StringField} which records a standardized CSS color string.
*/
class ColorField extends StringField {
/** @inheritdoc */
static get _defaults() {
return mergeObject(super._defaults, {
nullable: true,
initial: null,
blank: false,
validationError: "is not a valid hexadecimal color string"
});
}
/** @override */
initialize(value, model, options={}) {
if ( (value === null) || (value === undefined) ) return value;
return Color.from(value);
}
/** @override */
getInitialValue(data) {
const value = super.getInitialValue(data);
if ( (value === undefined) || (value === null) || (value === "") ) return value;
const color = Color.from(value);
if ( !color.valid ) throw new Error("Invalid initial value for ColorField");
return color.css;
}
/** @override */
_cast(value) {
if ( value === "" ) return value;
return Color.from(value);
}
/** @override */
_cleanType(value, options) {
if ( value === "" ) return value;
if ( value.valid ) return value.css;
return this.getInitialValue(options.source);
}
/** @inheritdoc */
_validateType(value, options) {
const result = super._validateType(value, options);
if ( result !== undefined ) return result;
if ( !isColorString(value) ) throw new Error("must be a valid color string");
}
/* -------------------------------------------- */
/* Form Field Integration */
/* -------------------------------------------- */
/** @override */
_toInput(config) {
if ( (config.placeholder === undefined) && !this.nullable && !(this.initial instanceof Function) ) {
config.placeholder = this.initial;
}
return foundry.applications.elements.HTMLColorPickerElement.create(config);
}
}
/* -------------------------------------------- */
/**
* @typedef {StringFieldOptions} FilePathFieldOptions
* @property {string[]} [categories] A set of categories in CONST.FILE_CATEGORIES which this field supports
* @property {boolean} [base64=false] Is embedded base64 data supported in lieu of a file path?
* @property {boolean} [wildcard=false] Does this file path field allow wildcard characters?
* @property {object} [initial] The initial values of the fields
*/
/**
* A special [StringField]{@link StringField} which records a file path or inline base64 data.
* @property {string[]} categories A set of categories in CONST.FILE_CATEGORIES which this field supports
* @property {boolean} base64=false Is embedded base64 data supported in lieu of a file path?
* @property {boolean} wildcard=false Does this file path field allow wildcard characters?
*/
class FilePathField extends StringField {
/**
* @param {FilePathFieldOptions} [options] Options which configure the behavior of the field
* @param {DataFieldContext} [context] Additional context which describes the field
*/
constructor(options={}, context={}) {
super(options, context);
if ( !this.categories.length || this.categories.some(c => !(c in FILE_CATEGORIES)) ) {
throw new Error("The categories of a FilePathField must be keys in CONST.FILE_CATEGORIES");
}
}
/** @inheritdoc */
static get _defaults() {
return mergeObject(super._defaults, {
categories: [],
base64: false,
wildcard: false,
nullable: true,
blank: false,
initial: null
});
}
/* -------------------------------------------- */
/** @inheritdoc */
_validateType(value) {
// Wildcard paths
if ( this.wildcard && value.includes("*") ) return true;
// Allowed extension or base64
const isValid = this.categories.some(c => {
const category = FILE_CATEGORIES[c];
if ( hasFileExtension(value, Object.keys(category)) ) return true;
/**
* If the field contains base64 data, it is allowed (for now) regardless of the base64 setting for the field.
* Eventually, this will become more strict and only be valid if base64 is configured as true for the field.
* @deprecated since v10
*/
return isBase64Data(value, Object.values(category));
});
// Throw an error for invalid paths
if ( !isValid ) {
let err = "does not have a valid file extension";
if ( this.base64 ) err += " or provide valid base64 data";
throw new Error(err);
}
}
/* -------------------------------------------- */
/* Form Field Integration */
/* -------------------------------------------- */
/** @override */
_toInput(config) {
// FIXME: This logic is fragile and would require a mapping between CONST.FILE_CATEGORIES and FilePicker.TYPES
config.type = this.categories.length === 1 ? this.categories[0].toLowerCase() : "any";
return foundry.applications.elements.HTMLFilePickerElement.create(config);
}
}
/* -------------------------------------------- */
/**
* A special {@link NumberField} which represents an angle of rotation in degrees between 0 and 360.
* @property {boolean} normalize Whether the angle should be normalized to [0,360) before being clamped to [0,360]. The default is true.
*/
class AngleField extends NumberField {
constructor(options={}, context={}) {
super(options, context)
if ( "base" in this.options ) this.base = this.options.base;
}
/** @inheritdoc */
static get _defaults() {
return mergeObject(super._defaults, {
required: true,
nullable: false,
initial: 0,
normalize: true,
min: 0,
max: 360,
validationError: "is not a number between 0 and 360"
});
}
/** @inheritdoc */
_cast(value) {
value = super._cast(value);
if ( !this.normalize ) return value;
value = Math.normalizeDegrees(value);
/** @deprecated since v12 */
if ( (this.#base === 360) && (value === 0) ) value = 360;
return value;
}
/* -------------------------------------------- */
/* Deprecations and Compatibility */
/* -------------------------------------------- */
/**
* @deprecated since v12
* @ignore
*/
get base() {
const msg = "The AngleField#base is deprecated in favor of AngleField#normalize.";
foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14});
return this.#base;
}
/**
* @deprecated since v12
* @ignore
*/
set base(v) {
const msg = "The AngleField#base is deprecated in favor of AngleField#normalize.";
foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14});
this.#base = v;
}
/**
* @deprecated since v12
* @ignore
*/
#base = 0;
}
/* -------------------------------------------- */
/**
* A special [NumberField]{@link NumberField} represents a number between 0 and 1.
*/
class AlphaField extends NumberField {
static get _defaults() {
return mergeObject(super._defaults, {
required: true,
nullable: false,
initial: 1,
min: 0,
max: 1,
validationError: "is not a number between 0 and 1"
});
}
}
/* -------------------------------------------- */
/**
* A special [NumberField]{@link NumberField} represents a number between 0 (inclusive) and 1 (exclusive).
* Its values are normalized (modulo 1) to the range [0, 1) instead of being clamped.
*/
class HueField extends NumberField {
static get _defaults() {
return mergeObject(super._defaults, {
required: true,
nullable: false,
initial: 0,
min: 0,
max: 1,
validationError: "is not a number between 0 (inclusive) and 1 (exclusive)"
});
}
/* -------------------------------------------- */
/** @inheritdoc */
_cast(value) {
value = super._cast(value) % 1;
if ( value < 0 ) value += 1;
return value;
}
}
/* -------------------------------------------- */
/**
* A special [ObjectField]{@link ObjectField} which captures a mapping of User IDs to Document permission levels.
*/
class DocumentOwnershipField extends ObjectField {
/** @inheritdoc */
static get _defaults() {
return mergeObject(super._defaults, {
initial: {"default": DOCUMENT_OWNERSHIP_LEVELS.NONE},
validationError: "is not a mapping of user IDs and document permission levels"
});
}
/** @override */
_validateType(value) {
for ( let [k, v] of Object.entries(value) ) {
if ( k.startsWith("-=") ) return isValidId(k.slice(2)) && (v === null); // Allow removals
if ( (k !== "default") && !isValidId(k) ) return false;
if ( !Object.values(DOCUMENT_OWNERSHIP_LEVELS).includes(v) ) return false;
}
}
}
/* -------------------------------------------- */
/**
* A special [StringField]{@link StringField} which contains serialized JSON data.
*/
class JSONField extends StringField {
constructor(options, context) {
super(options, context)
this.blank = false;
this.trim = false;
this.choices = undefined;
}
/** @inheritdoc */
static get _defaults() {
return mergeObject(super._defaults, {
blank: false,
trim: false,
initial: undefined,
validationError: "is not a valid JSON string"
});
}
/** @inheritdoc */
clean(value, options) {
if ( value === "" ) return '""'; // Special case for JSON fields
return super.clean(value, options);
}
/** @override */
_cast(value) {
if ( (typeof value !== "string") || !isJSON(value) ) return JSON.stringify(value);
return value;
}
/** @override */
_validateType(value, options) {
if ( (typeof value !== "string") || !isJSON(value) ) throw new Error("must be a serialized JSON string");
}
/** @override */
initialize(value, model, options={}) {
if ( (value === undefined) || (value === null) ) return value;
return JSON.parse(value);
}
/** @override */
toObject(value) {
if ( (value === undefined) || (this.nullable && (value === null)) ) return value;
return JSON.stringify(value);
}
/* -------------------------------------------- */
/* Form Field Integration */
/* -------------------------------------------- */
/** @override */
_toInput(config) {
if ( config.value !== "" ) config.value = JSON.stringify(config.value, null, 2);
return foundry.applications.fields.createTextareaInput(config);
}
}
/* -------------------------------------------- */
/**
* A special subclass of {@link DataField} which can contain any value of any type.
* Any input is accepted and is treated as valid.
* It is not recommended to use this class except for very specific circumstances.
*/
class AnyField extends DataField {
/** @override */
_cast(value) {
return value;
}
/** @override */
_validateType(value) {
return true;
}
}
/* -------------------------------------------- */
/**
* A subclass of [StringField]{@link StringField} which contains a sanitized HTML string.
* This class does not override any StringField behaviors, but is used by the server-side to identify fields which
* require sanitization of user input.
*/
class HTMLField extends StringField {
/** @inheritdoc */
static get _defaults() {
return mergeObject(super._defaults, {
required: true,
blank: true
});
}
/** @override */
toFormGroup(groupConfig={}, inputConfig) {
groupConfig.stacked ??= true;
return super.toFormGroup(groupConfig, inputConfig);
}
/** @override */
_toInput(config) {
return foundry.applications.elements.HTMLProseMirrorElement.create(config);
}
}
/* ---------------------------------------- */
/**
* A subclass of {@link NumberField} which is used for storing integer sort keys.
*/
class IntegerSortField extends NumberField {
/** @inheritdoc */
static get _defaults() {
return mergeObject(super._defaults, {
required: true,
nullable: false,
integer: true,
initial: 0,
label: "FOLDER.DocumentSort",
hint: "FOLDER.DocumentSortHint"
});
}
}
/* ---------------------------------------- */
/**
* @typedef {Object} DocumentStats
* @property {string|null} coreVersion The core version whose schema the Document data is in.
* It is NOT the version the Document was created or last modified in.
* @property {string|null} systemId The package name of the system the Document was created in.
* @property {string|null} systemVersion The version of the system the Document was created or last modified in.
* @property {number|null} createdTime A timestamp of when the Document was created.
* @property {number|null} modifiedTime A timestamp of when the Document was last modified.
* @property {string|null} lastModifiedBy The ID of the user who last modified the Document.
* @property {string|null} compendiumSource The UUID of the compendium Document this one was imported from.
* @property {string|null} duplicateSource The UUID of the Document this one is a duplicate of.
*/
/**
* A subclass of {@link SchemaField} which stores document metadata in the _stats field.
* @mixes DocumentStats
*/
class DocumentStatsField extends SchemaField {
/**
* @param {DataFieldOptions} [options] Options which configure the behavior of the field
* @param {DataFieldContext} [context] Additional context which describes the field
*/
constructor(options={}, context={}) {
super({
coreVersion: new StringField({required: true, blank: false, nullable: true, initial: () => game.release.version}),
systemId: new StringField({required: true, blank: false, nullable: true, initial: () => game.system?.id ?? null}),
systemVersion: new StringField({required: true, blank: false, nullable: true, initial: () => game.system?.version ?? null}),
createdTime: new NumberField(),
modifiedTime: new NumberField(),
lastModifiedBy: new ForeignDocumentField(foundry.documents.BaseUser, {idOnly: true}),
compendiumSource: new DocumentUUIDField(),
duplicateSource: new DocumentUUIDField()
}, options, context);
}
/**
* All Document stats.
* @type {string[]}
*/
static fields = [
"coreVersion", "systemId", "systemVersion", "createdTime", "modifiedTime", "lastModifiedBy", "compendiumSource",
"duplicateSource"
];
/**
* These fields are managed by the server and are ignored if they appear in creation or update data.
* @type {string[]}
*/
static managedFields = ["coreVersion", "systemId", "systemVersion", "createdTime", "modifiedTime", "lastModifiedBy"];
}
/* ---------------------------------------- */
/**
* A subclass of [StringField]{@link StringField} that is used specifically for the Document "type" field.
*/
class DocumentTypeField extends StringField {
/**
* @param {typeof foundry.abstract.Document} documentClass The base document class which belongs in this field
* @param {StringFieldOptions} [options] Options which configure the behavior of the field
* @param {DataFieldContext} [context] Additional context which describes the field
*/
constructor(documentClass, options={}, context={}) {
options.choices = () => documentClass.TYPES;
options.validationError = `is not a valid type for the ${documentClass.documentName} Document class`;
super(options, context);
}
/** @inheritdoc */
static get _defaults() {
return mergeObject(super._defaults, {
required: true,
nullable: false,
blank: false
});
}
/** @override */
_validateType(value, options) {
if ( (typeof value !== "string") || !value ) throw new Error("must be a non-blank string");
if ( this._isValidChoice(value) ) return true;
// Allow unrecognized types if we are allowed to fallback (non-strict validation)
if (options.fallback ) return true;
throw new Error(`"${value}" ${this.options.validationError}`);
}
}
/* ---------------------------------------- */
/**
* A subclass of [ObjectField]{@link ObjectField} which supports a type-specific data object.
*/
class TypeDataField extends ObjectField {
/**
* @param {typeof foundry.abstract.Document} document The base document class which belongs in this field
* @param {DataFieldOptions} [options] Options which configure the behavior of the field
* @param {DataFieldContext} [context] Additional context which describes the field
*/
constructor(document, options={}, context={}) {
super(options, context);
/**
* The canonical document name of the document type which belongs in this field
* @type {typeof foundry.abstract.Document}
*/
this.document = document;
}
/** @inheritdoc */
static get _defaults() {
return mergeObject(super._defaults, {required: true});
}
/** @override */
static recursive = true;
/**
* Return the package that provides the sub-type for the given model.
* @param {DataModel} model The model instance created for this sub-type.
* @returns {System|Module|null}
*/
static getModelProvider(model) {
const document = model.parent;
if ( !document ) return null;
const documentClass = document.constructor;
const documentName = documentClass.documentName;
const type = document.type;
// Unrecognized type
if ( !documentClass.TYPES.includes(type) ) return null;
// Core-defined sub-type
const coreTypes = documentClass.metadata.coreTypes;
if ( coreTypes.includes(type) ) return null;
// System-defined sub-type
const systemTypes = game.system.documentTypes[documentName];
if ( systemTypes && (type in systemTypes) ) return game.system;
// Module-defined sub-type
const moduleId = type.substring(0, type.indexOf("."));
return game.modules.get(moduleId) ?? null;
}
/**
* A convenience accessor for the name of the document type associated with this TypeDataField
* @type {string}
*/
get documentName() {
return this.document.documentName;
}
/**
* Get the DataModel definition that should be used for this type of document.
* @param {string} type The Document instance type
* @returns {typeof DataModel|null} The DataModel class or null
*/
getModelForType(type) {
if ( !type ) return null;
return globalThis.CONFIG?.[this.documentName]?.dataModels?.[type] ?? null;
}
/** @override */
getInitialValue(data) {
const cls = this.getModelForType(data.type);
if ( cls ) return cls.cleanData();
const template = game?.model[this.documentName]?.[data.type];
if ( template ) return foundry.utils.deepClone(template);
return {};
}
/** @override */
_cleanType(value, options) {
if ( !(typeof value === "object") ) value = {};
// Use a defined DataModel
const type = options.source?.type;
const cls = this.getModelForType(type);
if ( cls ) return cls.cleanData(value, {...options, source: value});
if ( options.partial ) return value;
// Use the defined template.json
const template = this.getInitialValue(options.source);
const insertKeys = (type === BASE_DOCUMENT_TYPE) || !game?.system?.strictDataCleaning;
return mergeObject(template, value, {insertKeys, inplace: true});
}
/** @override */
initialize(value, model, options={}) {
const cls = this.getModelForType(model._source.type);
if ( cls ) {
const instance = new cls(value, {parent: model, ...options});
if ( !("modelProvider" in instance) ) Object.defineProperty(instance, "modelProvider", {
value: this.constructor.getModelProvider(instance),
writable: false
});
return instance;
}
return deepClone(value);
}
/** @inheritdoc */
_validateType(data, options={}) {
const result = super._validateType(data, options);
if ( result !== undefined ) return result;
const cls = this.getModelForType(options.source?.type);
const schema = cls?.schema;
return schema?.validate(data, {...options, source: data});
}
/* ---------------------------------------- */
/** @override */
_validateModel(changes, options={}) {
const cls = this.getModelForType(options.source?.type);
return cls?.validateJoint(changes);
}
/* ---------------------------------------- */
/** @override */
toObject(value) {
return value.toObject instanceof Function ? value.toObject(false) : deepClone(value);
}
/**
* Migrate this field's candidate source data.
* @param {object} sourceData Candidate source data of the root model
* @param {any} fieldData The value of this field within the source data
*/
migrateSource(sourceData, fieldData) {
const cls = this.getModelForType(sourceData.type);
if ( cls ) cls.migrateDataSafe(fieldData);
}
}
/* ---------------------------------------- */
/**
* A subclass of [DataField]{@link DataField} which allows to typed schemas.
*/
class TypedSchemaField extends DataField {
/**
* @param {{[type: string]: DataSchema|SchemaField|typeof DataModel}} types The different types this field can represent.
* @param {DataFieldOptions} [options] Options which configure the behavior of the field
* @param {DataFieldContext} [context] Additional context which describes the field
*/
constructor(types, options, context) {
super(options, context);
this.types = this.#configureTypes(types);
}
/* ---------------------------------------- */
/** @inheritdoc */
static get _defaults() {
return mergeObject(super._defaults, {required: true});
}
/* ---------------------------------------- */
/**
* The types of this field.
* @type {{[type: string]: SchemaField}}
*/
types;
/* -------------------------------------------- */
/**
* Initialize and validate the structure of the provided type definitions.
* @param {{[type: string]: DataSchema|SchemaField|typeof DataModel}} types The provided field definitions
* @returns {{[type: string]: SchemaField}} The validated fields
*/
#configureTypes(types) {
if ( (typeof types !== "object") ) {
throw new Error("A DataFields must be an object with string keys and DataField values.");
}
types = {...types};
for ( let [type, field] of Object.entries(types) ) {
if ( isSubclass(field, DataModel) ) field = new EmbeddedDataField(field);
if ( field?.constructor?.name === "Object" ) {
const schema = {...field};
if ( !("type" in schema) ) {
schema.type = new StringField({required: true, blank: false, initial: field,
validate: value => value === type, validationError: `must be equal to "${type}"`});
}
field = new SchemaField(schema);
}
if ( !(field instanceof SchemaField) ) {
throw new Error(`The "${type}" field is not an instance of the SchemaField class or a subclass of DataModel.`);
}
if ( field.name !== undefined ) throw new Error(`The "${field.fieldPath}" field must not have a name.`);
if ( field.parent !== undefined ) {
throw new Error(`The "${field.fieldPath}" field already belongs to some other parent and may not be reused.`);
}
types[type] = field;
field.parent = this;
if ( !field.required ) throw new Error(`The "${field.fieldPath}" field must be required.`);
if ( field.nullable ) throw new Error(`The "${field.fieldPath}" field must not be nullable.`);
const typeField = field.fields.type;
if ( !(typeField instanceof StringField) ) throw new Error(`The "${field.fieldPath}" field must have a "type" StringField.`);
if ( !typeField.required ) throw new Error(`The "${typeField.fieldPath}" field must be required.`);
if ( typeField.nullable ) throw new Error(`The "${typeField.fieldPath}" field must not be nullable.`);
if ( typeField.blank ) throw new Error(`The "${typeField.fieldPath}" field must not be blank.`);
if ( typeField.validate(type, {fallback: false}) !== undefined ) throw new Error(`"${type}" must be a valid type of "${typeField.fieldPath}".`);
}
return types;
}
/* ---------------------------------------- */
/** @override */
_getField(path) {
if ( !path.length ) return this;
return this.types[path.shift()]?._getField(path);
}
/* -------------------------------------------- */
/* Data Field Methods */
/* -------------------------------------------- */
/** @override */
_cleanType(value, options) {
const field = this.types[value?.type];
if ( !field ) return value;
return field.clean(value, options);
}
/* ---------------------------------------- */
/** @override */
_cast(value) {
return typeof value === "object" ? value : {};
}
/* ---------------------------------------- */
/** @override */
_validateSpecial(value) {
const result = super._validateSpecial(value);
if ( result !== undefined ) return result;
const field = this.types[value?.type];
if ( !field ) throw new Error("does not have a valid type");
}
/* ---------------------------------------- */
/** @override */
_validateType(value, options) {
return this.types[value.type].validate(value, options);
}
/* ---------------------------------------- */
/** @override */
initialize(value, model, options) {
const field = this.types[value?.type];
if ( !field ) return value;
return field.initialize(value, model, options);
}
/* ---------------------------------------- */
/** @override */
toObject(value) {
if ( !value ) return value;
return this.types[value.type]?.toObject(value) ?? value;
}
/* -------------------------------------------- */
/** @override */
apply(fn, data, options) {
// Apply to this TypedSchemaField
const thisFn = typeof fn === "string" ? this[fn] : fn;
thisFn?.call(this, data, options);
// Apply to the inner typed field
const typeField = this.types[data?.type];
return typeField?.apply(fn, data, options);
}
/* -------------------------------------------- */
/**
* Migrate this field's candidate source data.
* @param {object} sourceData Candidate source data of the root model
* @param {any} fieldData The value of this field within the source data
*/
migrateSource(sourceData, fieldData) {
const field = this.types[fieldData?.type];
const canMigrate = field?.migrateSource instanceof Function;
if ( canMigrate ) field.migrateSource(sourceData, fieldData);
}
}
/* ---------------------------------------- */
/* DEPRECATIONS */
/* ---------------------------------------- */
/**
* @deprecated since v11
* @see DataModelValidationError
* @ignore
*/
class ModelValidationError extends Error {
constructor(errors) {
logCompatibilityWarning(
"ModelValidationError is deprecated. Please use DataModelValidationError instead.",
{since: 11, until: 13});
const message = ModelValidationError.formatErrors(errors);
super(message);
this.errors = errors;
}
/**
* Collect all the errors into a single message for consumers who do not handle the ModelValidationError specially.
* @param {Record<string, Error>|Error[]|string} errors The raw error structure
* @returns {string} A formatted error message
*/
static formatErrors(errors) {
if ( typeof errors === "string" ) return errors;
const message = ["Model Validation Errors"];
if ( errors instanceof Array ) message.push(...errors.map(e => e.message));
else message.push(...Object.entries(errors).map(([k, e]) => `[${k}]: ${e.message}`));
return message.join("\n");
}
}
/* -------------------------------------------- */
/**
* @typedef {Object} JavaScriptFieldOptions
* @property {boolean} [async=false] Does the field allow async code?
*/
/**
* A subclass of {@link StringField} which contains JavaScript code.
*/
class JavaScriptField extends StringField {
/**
* @param {StringFieldOptions & JavaScriptFieldOptions} [options] Options which configure the behavior of the field
* @param {DataFieldContext} [context] Additional context which describes the field
*/
constructor(options, context) {
super(options, context);
this.choices = undefined;
}
/** @inheritdoc */
static get _defaults() {
return mergeObject(super._defaults, {
required: true,
blank: true,
nullable: false,
async: false
});
}
/** @inheritdoc */
_validateType(value, options) {
const result = super._validateType(value, options);
if ( result !== undefined ) return result;
try {
new (this.async ? AsyncFunction : Function)(value);
} catch(err) {
const scope = this.async ? "an asynchronous" : "a synchronous";
err.message = `must be valid JavaScript for ${scope} scope:\n${err.message}`;
throw new Error(err);
}
}
/* -------------------------------------------- */
/* Form Field Integration */
/* -------------------------------------------- */
/** @override */
toFormGroup(groupConfig={}, inputConfig) {
groupConfig.stacked ??= true;
return super.toFormGroup(groupConfig, inputConfig);
}
/** @override */
_toInput(config) {
return foundry.applications.fields.createTextareaInput(config);
}
}
// Exports need to be at the bottom so that class names appear correctly in JSDoc
export {
AlphaField,
AngleField,
AnyField,
ArrayField,
BooleanField,
ColorField,
DataField,
DocumentIdField,
DocumentOwnershipField,
DocumentStatsField,
DocumentTypeField,
DocumentUUIDField,
EmbeddedDataField,
EmbeddedCollectionField,
EmbeddedCollectionDeltaField,
EmbeddedDocumentField,
FilePathField,
ForeignDocumentField,
HTMLField,
HueField,
IntegerSortField,
JavaScriptField,
JSONField,
NumberField,
ObjectField,
TypedSchemaField,
SchemaField,
SetField,
StringField,
TypeDataField,
ModelValidationError
}