Initial
This commit is contained in:
524
resources/app/common/data/data.mjs
Normal file
524
resources/app/common/data/data.mjs
Normal file
@@ -0,0 +1,524 @@
|
||||
/**
|
||||
* The collection of data schema and document definitions for primary documents which are shared between the both the
|
||||
* client and the server.
|
||||
* @namespace data
|
||||
*/
|
||||
|
||||
import {DataModel} from "../abstract/module.mjs";
|
||||
import * as fields from "./fields.mjs";
|
||||
import * as documents from "../documents/_module.mjs";
|
||||
import {logCompatibilityWarning} from "../utils/logging.mjs";
|
||||
|
||||
/**
|
||||
* @typedef {import("./fields.mjs").DataFieldOptions} DataFieldOptions
|
||||
* @typedef {import("./fields.mjs").FilePathFieldOptions} FilePathFieldOptions
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} LightAnimationData
|
||||
* @property {string} type The animation type which is applied
|
||||
* @property {number} speed The speed of the animation, a number between 0 and 10
|
||||
* @property {number} intensity The intensity of the animation, a number between 1 and 10
|
||||
* @property {boolean} reverse Reverse the direction of animation.
|
||||
*/
|
||||
|
||||
/**
|
||||
* A reusable document structure for the internal data used to render the appearance of a light source.
|
||||
* This is re-used by both the AmbientLightData and TokenData classes.
|
||||
* @extends DataModel
|
||||
* @memberof data
|
||||
*
|
||||
* @property {boolean} negative Is this light source a negative source? (i.e. darkness source)
|
||||
* @property {number} alpha An opacity for the emitted light, if any
|
||||
* @property {number} angle The angle of emission for this point source
|
||||
* @property {number} bright The allowed radius of bright vision or illumination
|
||||
* @property {number} color A tint color for the emitted light, if any
|
||||
* @property {number} coloration The coloration technique applied in the shader
|
||||
* @property {number} contrast The amount of contrast this light applies to the background texture
|
||||
* @property {number} dim The allowed radius of dim vision or illumination
|
||||
* @property {number} attenuation Fade the difference between bright, dim, and dark gradually?
|
||||
* @property {number} luminosity The luminosity applied in the shader
|
||||
* @property {number} saturation The amount of color saturation this light applies to the background texture
|
||||
* @property {number} shadows The depth of shadows this light applies to the background texture
|
||||
* @property {LightAnimationData} animation An animation configuration for the source
|
||||
* @property {{min: number, max: number}} darkness A darkness range (min and max) for which the source should be active
|
||||
*/
|
||||
class LightData extends DataModel {
|
||||
static defineSchema() {
|
||||
return {
|
||||
negative: new fields.BooleanField(),
|
||||
priority: new fields.NumberField({required: true, nullable: false, integer: true, initial: 0, min: 0}),
|
||||
alpha: new fields.AlphaField({initial: 0.5}),
|
||||
angle: new fields.AngleField({initial: 360, normalize: false}),
|
||||
bright: new fields.NumberField({required: true, nullable: false, initial: 0, min: 0, step: 0.01}),
|
||||
color: new fields.ColorField({}),
|
||||
coloration: new fields.NumberField({required: true, integer: true, initial: 1}),
|
||||
dim: new fields.NumberField({required: true, nullable: false, initial: 0, min: 0, step: 0.01}),
|
||||
attenuation: new fields.AlphaField({initial: 0.5}),
|
||||
luminosity: new fields.NumberField({required: true, nullable: false, initial: 0.5, min: 0, max: 1}),
|
||||
saturation: new fields.NumberField({required: true, nullable: false, initial: 0, min: -1, max: 1}),
|
||||
contrast: new fields.NumberField({required: true, nullable: false, initial: 0, min: -1, max: 1}),
|
||||
shadows: new fields.NumberField({required: true, nullable: false, initial: 0, min: 0, max: 1}),
|
||||
animation: new fields.SchemaField({
|
||||
type: new fields.StringField({nullable: true, blank: false, initial: null}),
|
||||
speed: new fields.NumberField({required: true, nullable: false, integer: true, initial: 5, min: 0, max: 10,
|
||||
validationError: "Light animation speed must be an integer between 0 and 10"}),
|
||||
intensity: new fields.NumberField({required: true, nullable: false, integer: true, initial: 5, min: 1, max: 10,
|
||||
validationError: "Light animation intensity must be an integer between 1 and 10"}),
|
||||
reverse: new fields.BooleanField()
|
||||
}),
|
||||
darkness: new fields.SchemaField({
|
||||
min: new fields.AlphaField({initial: 0}),
|
||||
max: new fields.AlphaField({initial: 1})
|
||||
}, {
|
||||
validate: d => (d.min ?? 0) <= (d.max ?? 1),
|
||||
validationError: "darkness.max may not be less than darkness.min"
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/** @override */
|
||||
static LOCALIZATION_PREFIXES = ["LIGHT"];
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Deprecations and Compatibility */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
static migrateData(data) {
|
||||
/**
|
||||
* Migration of negative luminosity
|
||||
* @deprecated since v12
|
||||
*/
|
||||
const luminosity = data.luminosity;
|
||||
if ( luminosity < 0) {
|
||||
data.luminosity = 1 - luminosity;
|
||||
data.negative = true;
|
||||
}
|
||||
return super.migrateData(data);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/**
|
||||
* A data model intended to be used as an inner EmbeddedDataField which defines a geometric shape.
|
||||
* @extends DataModel
|
||||
* @memberof data
|
||||
*
|
||||
* @property {string} type The type of shape, a value in ShapeData.TYPES.
|
||||
* For rectangles, the x/y coordinates are the top-left corner.
|
||||
* For circles, the x/y coordinates are the center of the circle.
|
||||
* For polygons, the x/y coordinates are the first point of the polygon.
|
||||
* @property {number} [width] For rectangles, the pixel width of the shape.
|
||||
* @property {number} [height] For rectangles, the pixel width of the shape.
|
||||
* @property {number} [radius] For circles, the pixel radius of the shape.
|
||||
* @property {number[]} [points] For polygons, the array of polygon coordinates which comprise the shape.
|
||||
*/
|
||||
class ShapeData extends DataModel {
|
||||
static defineSchema() {
|
||||
return {
|
||||
type: new fields.StringField({required: true, blank: false, choices: Object.values(this.TYPES), initial: "r"}),
|
||||
width: new fields.NumberField({required: false, integer: true, min: 0}),
|
||||
height: new fields.NumberField({required: false, integer: true, min: 0}),
|
||||
radius: new fields.NumberField({required: false, integer: true, positive: true}),
|
||||
points: new fields.ArrayField(new fields.NumberField({nullable: false}))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The primitive shape types which are supported
|
||||
* @enum {string}
|
||||
*/
|
||||
static TYPES = {
|
||||
RECTANGLE: "r",
|
||||
CIRCLE: "c",
|
||||
ELLIPSE: "e",
|
||||
POLYGON: "p"
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/**
|
||||
* A data model intended to be used as an inner EmbeddedDataField which defines a geometric shape.
|
||||
* @extends DataModel
|
||||
* @memberof data
|
||||
* @abstract
|
||||
*
|
||||
* @property {string} type The type of shape, a value in BaseShapeData.TYPES.
|
||||
* @property {{bottom: number|null, top: number|null}} [elevation] The bottom and top elevation of the shape.
|
||||
* A value of null means -/+Infinity.
|
||||
* @property {boolean} [hole=false] Is this shape a hole?
|
||||
*/
|
||||
class BaseShapeData extends DataModel {
|
||||
|
||||
/**
|
||||
* The possible shape types.
|
||||
* @type {Readonly<{
|
||||
* rectangle: RectangleShapeData,
|
||||
* circle: CircleShapeData,
|
||||
* ellipse: EllipseShapeData,
|
||||
* polygon: PolygonShapeData
|
||||
* }>}
|
||||
*/
|
||||
static get TYPES() {
|
||||
return BaseShapeData.#TYPES ??= Object.freeze({
|
||||
[RectangleShapeData.TYPE]: RectangleShapeData,
|
||||
[CircleShapeData.TYPE]: CircleShapeData,
|
||||
[EllipseShapeData.TYPE]: EllipseShapeData,
|
||||
[PolygonShapeData.TYPE]: PolygonShapeData
|
||||
});
|
||||
}
|
||||
|
||||
static #TYPES;
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The type of this shape.
|
||||
* @type {string}
|
||||
*/
|
||||
static TYPE = "";
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
static defineSchema() {
|
||||
return {
|
||||
type: new fields.StringField({required: true, blank: false, initial: this.TYPE,
|
||||
validate: value => value === this.TYPE, validationError: `must be equal to "${this.TYPE}"`}),
|
||||
hole: new fields.BooleanField()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The data model for a rectangular shape.
|
||||
* @extends DataModel
|
||||
* @memberof data
|
||||
*
|
||||
* @property {number} x The top-left x-coordinate in pixels before rotation.
|
||||
* @property {number} y The top-left y-coordinate in pixels before rotation.
|
||||
* @property {number} width The width of the rectangle in pixels.
|
||||
* @property {number} height The height of the rectangle in pixels.
|
||||
* @property {number} [rotation=0] The rotation around the center of the rectangle in degrees.
|
||||
*/
|
||||
class RectangleShapeData extends BaseShapeData {
|
||||
|
||||
static {
|
||||
Object.defineProperty(this, "TYPE", {value: "rectangle"});
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
static defineSchema() {
|
||||
return Object.assign(super.defineSchema(), {
|
||||
x: new fields.NumberField({required: true, nullable: false, initial: undefined}),
|
||||
y: new fields.NumberField({required: true, nullable: false, initial: undefined}),
|
||||
width: new fields.NumberField({required: true, nullable: false, initial: undefined, positive: true}),
|
||||
height: new fields.NumberField({required: true, nullable: false, initial: undefined, positive: true}),
|
||||
rotation: new fields.AngleField()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The data model for a circle shape.
|
||||
* @extends DataModel
|
||||
* @memberof data
|
||||
*
|
||||
* @property {number} x The x-coordinate of the center point in pixels.
|
||||
* @property {number} y The y-coordinate of the center point in pixels.
|
||||
* @property {number} radius The radius of the circle in pixels.
|
||||
*/
|
||||
class CircleShapeData extends BaseShapeData {
|
||||
|
||||
static {
|
||||
Object.defineProperty(this, "TYPE", {value: "circle"});
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
static defineSchema() {
|
||||
return Object.assign(super.defineSchema(), {
|
||||
x: new fields.NumberField({required: true, nullable: false, initial: undefined}),
|
||||
y: new fields.NumberField({required: true, nullable: false, initial: undefined}),
|
||||
radius: new fields.NumberField({required: true, nullable: false, initial: undefined, positive: true})
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The data model for an ellipse shape.
|
||||
* @extends DataModel
|
||||
* @memberof data
|
||||
*
|
||||
* @property {number} x The x-coordinate of the center point in pixels.
|
||||
* @property {number} y The y-coordinate of the center point in pixels.
|
||||
* @property {number} radiusX The x-radius of the circle in pixels.
|
||||
* @property {number} radiusY The y-radius of the circle in pixels.
|
||||
* @property {number} [rotation=0] The rotation around the center of the rectangle in degrees.
|
||||
*/
|
||||
class EllipseShapeData extends BaseShapeData {
|
||||
|
||||
static {
|
||||
Object.defineProperty(this, "TYPE", {value: "ellipse"});
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
static defineSchema() {
|
||||
return Object.assign(super.defineSchema(), {
|
||||
x: new fields.NumberField({required: true, nullable: false, initial: undefined}),
|
||||
y: new fields.NumberField({required: true, nullable: false, initial: undefined}),
|
||||
radiusX: new fields.NumberField({required: true, nullable: false, initial: undefined, positive: true}),
|
||||
radiusY: new fields.NumberField({required: true, nullable: false, initial: undefined, positive: true}),
|
||||
rotation: new fields.AngleField()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The data model for a polygon shape.
|
||||
* @extends DataModel
|
||||
* @memberof data
|
||||
*
|
||||
* @property {number[]} points The points of the polygon ([x0, y0, x1, y1, ...]).
|
||||
* The polygon must not be self-intersecting.
|
||||
*/
|
||||
class PolygonShapeData extends BaseShapeData {
|
||||
|
||||
static {
|
||||
Object.defineProperty(this, "TYPE", {value: "polygon"});
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
static defineSchema() {
|
||||
return Object.assign(super.defineSchema(), {
|
||||
points: new fields.ArrayField(new fields.NumberField({required: true, nullable: false, initial: undefined}),
|
||||
{validate: value => {
|
||||
if ( value.length % 2 !== 0 ) throw new Error("must have an even length");
|
||||
if ( value.length < 6 ) throw new Error("must have at least 3 points");
|
||||
}}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/**
|
||||
* A {@link fields.SchemaField} subclass used to represent texture data.
|
||||
* @property {string|null} src The URL of the texture source.
|
||||
* @property {number} [anchorX=0] The X coordinate of the texture anchor.
|
||||
* @property {number} [anchorY=0] The Y coordinate of the texture anchor.
|
||||
* @property {number} [scaleX=1] The scale of the texture in the X dimension.
|
||||
* @property {number} [scaleY=1] The scale of the texture in the Y dimension.
|
||||
* @property {number} [offsetX=0] The X offset of the texture with (0,0) in the top left.
|
||||
* @property {number} [offsetY=0] The Y offset of the texture with (0,0) in the top left.
|
||||
* @property {number} [rotation=0] An angle of rotation by which this texture is rotated around its center.
|
||||
* @property {string} [tint="#ffffff"] The tint applied to the texture.
|
||||
* @property {number} [alphaThreshold=0] Only pixels with an alpha value at or above this value are consider solid
|
||||
* w.r.t. to occlusion testing and light/weather blocking.
|
||||
*/
|
||||
class TextureData extends fields.SchemaField {
|
||||
/**
|
||||
* @param {DataFieldOptions} options Options which are forwarded to the SchemaField constructor
|
||||
* @param {FilePathFieldOptions} srcOptions Additional options for the src field
|
||||
*/
|
||||
constructor(options={}, {categories=["IMAGE", "VIDEO"], initial={}, wildcard=false, label=""}={}) {
|
||||
/** @deprecated since v12 */
|
||||
if ( typeof initial === "string" ) {
|
||||
const msg = "Passing the initial value of the src field as a string is deprecated. Pass {src} instead.";
|
||||
logCompatibilityWarning(msg, {since: 12, until: 14});
|
||||
initial = {src: initial};
|
||||
}
|
||||
super({
|
||||
src: new fields.FilePathField({categories, initial: initial.src ?? null, label, wildcard}),
|
||||
anchorX: new fields.NumberField({nullable: false, initial: initial.anchorX ?? 0}),
|
||||
anchorY: new fields.NumberField({nullable: false, initial: initial.anchorY ?? 0}),
|
||||
offsetX: new fields.NumberField({nullable: false, integer: true, initial: initial.offsetX ?? 0}),
|
||||
offsetY: new fields.NumberField({nullable: false, integer: true, initial: initial.offsetY ?? 0}),
|
||||
fit: new fields.StringField({initial: initial.fit ?? "fill", choices: CONST.TEXTURE_DATA_FIT_MODES}),
|
||||
scaleX: new fields.NumberField({nullable: false, initial: initial.scaleX ?? 1}),
|
||||
scaleY: new fields.NumberField({nullable: false, initial: initial.scaleY ?? 1}),
|
||||
rotation: new fields.AngleField({initial: initial.rotation ?? 0}),
|
||||
tint: new fields.ColorField({nullable: false, initial: initial.tint ?? "#ffffff"}),
|
||||
alphaThreshold: new fields.AlphaField({nullable: false, initial: initial.alphaThreshold ?? 0})
|
||||
}, options);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------------------------------- */
|
||||
|
||||
/**
|
||||
* Extend the base TokenData to define a PrototypeToken which exists within a parent Actor.
|
||||
* @extends abstract.DataModel
|
||||
* @memberof data
|
||||
* @property {boolean} randomImg Does the prototype token use a random wildcard image?
|
||||
* @alias {PrototypeToken}
|
||||
*/
|
||||
class PrototypeToken extends DataModel {
|
||||
constructor(data={}, options={}) {
|
||||
super(data, options);
|
||||
Object.defineProperty(this, "apps", {value: {}});
|
||||
}
|
||||
|
||||
/** @override */
|
||||
static defineSchema() {
|
||||
const schema = documents.BaseToken.defineSchema();
|
||||
const excluded = ["_id", "actorId", "delta", "x", "y", "elevation", "sort", "hidden", "locked", "_regions"];
|
||||
for ( let x of excluded ) {
|
||||
delete schema[x];
|
||||
}
|
||||
schema.name.textSearch = schema.name.options.textSearch = false;
|
||||
schema.randomImg = new fields.BooleanField();
|
||||
PrototypeToken.#applyDefaultTokenSettings(schema);
|
||||
return schema;
|
||||
}
|
||||
|
||||
/** @override */
|
||||
static LOCALIZATION_PREFIXES = ["TOKEN"];
|
||||
|
||||
/**
|
||||
* The Actor which owns this Prototype Token
|
||||
* @type {documents.BaseActor}
|
||||
*/
|
||||
get actor() {
|
||||
return this.parent;
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
toObject(source=true) {
|
||||
const data = super.toObject(source);
|
||||
data["actorId"] = this.document?.id;
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* @see ClientDocument.database
|
||||
* @ignore
|
||||
*/
|
||||
static get database() {
|
||||
return globalThis.CONFIG.DatabaseBackend;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Apply configured default token settings to the schema.
|
||||
* @param {DataSchema} [schema] The schema to apply the settings to.
|
||||
*/
|
||||
static #applyDefaultTokenSettings(schema) {
|
||||
if ( typeof DefaultTokenConfig === "undefined" ) return;
|
||||
const settings = foundry.utils.flattenObject(game.settings.get("core", DefaultTokenConfig.SETTING) ?? {});
|
||||
for ( const [k, v] of Object.entries(settings) ) {
|
||||
const path = k.split(".");
|
||||
let field = schema[path.shift()];
|
||||
if ( path.length ) field = field._getField(path);
|
||||
if ( field ) field.initial = v;
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Document Compatibility Methods */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* @see abstract.Document#update
|
||||
* @ignore
|
||||
*/
|
||||
update(data, options) {
|
||||
return this.actor.update({prototypeToken: data}, options);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* @see abstract.Document#getFlag
|
||||
* @ignore
|
||||
*/
|
||||
getFlag(...args) {
|
||||
return foundry.abstract.Document.prototype.getFlag.call(this, ...args);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* @see abstract.Document#getFlag
|
||||
* @ignore
|
||||
*/
|
||||
setFlag(...args) {
|
||||
return foundry.abstract.Document.prototype.setFlag.call(this, ...args);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* @see abstract.Document#unsetFlag
|
||||
* @ignore
|
||||
*/
|
||||
async unsetFlag(...args) {
|
||||
return foundry.abstract.Document.prototype.unsetFlag.call(this, ...args);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* @see abstract.Document#testUserPermission
|
||||
* @ignore
|
||||
*/
|
||||
testUserPermission(user, permission, {exact=false}={}) {
|
||||
return this.actor.testUserPermission(user, permission, {exact});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* @see documents.BaseActor#isOwner
|
||||
* @ignore
|
||||
*/
|
||||
get isOwner() {
|
||||
return this.actor.isOwner;
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* A minimal data model used to represent a tombstone entry inside an EmbeddedCollectionDelta.
|
||||
* @see {EmbeddedCollectionDelta}
|
||||
* @extends DataModel
|
||||
* @memberof data
|
||||
*
|
||||
* @property {string} _id The _id of the base Document that this tombstone represents.
|
||||
* @property {boolean} _tombstone A property that identifies this entry as a tombstone.
|
||||
*/
|
||||
class TombstoneData extends DataModel {
|
||||
/** @override */
|
||||
static defineSchema() {
|
||||
return {
|
||||
_id: new fields.DocumentIdField(),
|
||||
_tombstone: new fields.BooleanField({initial: true, validate: v => v === true, validationError: "must be true"})
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Exports need to be at the bottom so that class names appear correctly in JSDoc
|
||||
export {
|
||||
LightData,
|
||||
PrototypeToken,
|
||||
ShapeData,
|
||||
BaseShapeData,
|
||||
RectangleShapeData,
|
||||
CircleShapeData,
|
||||
EllipseShapeData,
|
||||
PolygonShapeData,
|
||||
TextureData,
|
||||
TombstoneData
|
||||
}
|
||||
2977
resources/app/common/data/fields.mjs
Normal file
2977
resources/app/common/data/fields.mjs
Normal file
File diff suppressed because it is too large
Load Diff
4
resources/app/common/data/module.mjs
Normal file
4
resources/app/common/data/module.mjs
Normal file
@@ -0,0 +1,4 @@
|
||||
export * as validators from "./validators.mjs";
|
||||
export * as validation from "./validation-failure.mjs";
|
||||
export * as fields from "./fields.mjs";
|
||||
export * from "./data.mjs";
|
||||
298
resources/app/common/data/validation-failure.mjs
Normal file
298
resources/app/common/data/validation-failure.mjs
Normal file
@@ -0,0 +1,298 @@
|
||||
import {isEmpty} from "../utils/helpers.mjs";
|
||||
|
||||
/**
|
||||
* A class responsible for recording information about a validation failure.
|
||||
*/
|
||||
export class DataModelValidationFailure {
|
||||
/**
|
||||
* @param {any} [invalidValue] The value that failed validation for this field.
|
||||
* @param {any} [fallback] The value it was replaced by, if any.
|
||||
* @param {boolean} [dropped=false] Whether the value was dropped from some parent collection.
|
||||
* @param {string} [message] The validation error message.
|
||||
* @param {boolean} [unresolved=false] Whether this failure was unresolved
|
||||
*/
|
||||
constructor({invalidValue, fallback, dropped=false, message, unresolved=false}={}) {
|
||||
this.invalidValue = invalidValue;
|
||||
this.fallback = fallback;
|
||||
this.dropped = dropped;
|
||||
this.message = message;
|
||||
this.unresolved = unresolved;
|
||||
}
|
||||
|
||||
/**
|
||||
* The value that failed validation for this field.
|
||||
* @type {any}
|
||||
*/
|
||||
invalidValue;
|
||||
|
||||
/**
|
||||
* The value it was replaced by, if any.
|
||||
* @type {any}
|
||||
*/
|
||||
fallback;
|
||||
|
||||
/**
|
||||
* Whether the value was dropped from some parent collection.
|
||||
* @type {boolean}
|
||||
*/
|
||||
dropped;
|
||||
|
||||
/**
|
||||
* The validation error message.
|
||||
* @type {string}
|
||||
*/
|
||||
message;
|
||||
|
||||
/**
|
||||
* If this field contains other fields that are validated as part of its validation, their results are recorded here.
|
||||
* @type {Record<string, DataModelValidationFailure>}
|
||||
*/
|
||||
fields = {};
|
||||
|
||||
/**
|
||||
* @typedef {object} ElementValidationFailure
|
||||
* @property {string|number} id Either the element's index or some other identifier for it.
|
||||
* @property {string} [name] Optionally a user-friendly name for the element.
|
||||
* @property {DataModelValidationFailure} failure The element's validation failure.
|
||||
*/
|
||||
|
||||
/**
|
||||
* If this field contains a list of elements that are validated as part of its validation, their results are recorded
|
||||
* here.
|
||||
* @type {ElementValidationFailure[]}
|
||||
*/
|
||||
elements = [];
|
||||
|
||||
/**
|
||||
* Record whether a validation failure is unresolved.
|
||||
* This reports as true if validation for this field or any hierarchically contained field is unresolved.
|
||||
* A failure is unresolved if the value was invalid and there was no valid fallback value available.
|
||||
* @type {boolean}
|
||||
*/
|
||||
unresolved;
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Return this validation failure as an Error object.
|
||||
* @returns {DataModelValidationError}
|
||||
*/
|
||||
asError() {
|
||||
return new DataModelValidationError(this);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Whether this failure contains other sub-failures.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isEmpty() {
|
||||
return isEmpty(this.fields) && isEmpty(this.elements);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Return the base properties of this failure, omitting any nested failures.
|
||||
* @returns {{invalidValue: any, fallback: any, dropped: boolean, message: string}}
|
||||
*/
|
||||
toObject() {
|
||||
const {invalidValue, fallback, dropped, message} = this;
|
||||
return {invalidValue, fallback, dropped, message};
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Represent the DataModelValidationFailure as a string.
|
||||
* @returns {string}
|
||||
*/
|
||||
toString() {
|
||||
return DataModelValidationFailure.#formatString(this);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Format a DataModelValidationFailure instance as a string message.
|
||||
* @param {DataModelValidationFailure} failure The failure instance
|
||||
* @param {number} _d An internal depth tracker
|
||||
* @returns {string} The formatted failure string
|
||||
*/
|
||||
static #formatString(failure, _d=0) {
|
||||
let message = failure.message ?? "";
|
||||
_d++;
|
||||
if ( !isEmpty(failure.fields) ) {
|
||||
message += "\n";
|
||||
const messages = [];
|
||||
for ( const [name, subFailure] of Object.entries(failure.fields) ) {
|
||||
const subMessage = DataModelValidationFailure.#formatString(subFailure, _d);
|
||||
messages.push(`${" ".repeat(2 * _d)}${name}: ${subMessage}`);
|
||||
}
|
||||
message += messages.join("\n");
|
||||
}
|
||||
if ( !isEmpty(failure.elements) ) {
|
||||
message += "\n";
|
||||
const messages = [];
|
||||
for ( const element of failure.elements ) {
|
||||
const subMessage = DataModelValidationFailure.#formatString(element.failure, _d);
|
||||
messages.push(`${" ".repeat(2 * _d)}${element.id}: ${subMessage}`);
|
||||
}
|
||||
message += messages.join("\n");
|
||||
}
|
||||
return message;
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* A specialised Error to indicate a model validation failure.
|
||||
* @extends {Error}
|
||||
*/
|
||||
export class DataModelValidationError extends Error {
|
||||
/**
|
||||
* @param {DataModelValidationFailure|string} failure The failure that triggered this error or an error message
|
||||
* @param {...any} [params] Additional Error constructor parameters
|
||||
*/
|
||||
constructor(failure, ...params) {
|
||||
super(failure.toString(), ...params);
|
||||
if ( failure instanceof DataModelValidationFailure ) this.#failure = failure;
|
||||
}
|
||||
|
||||
/**
|
||||
* The root validation failure that triggered this error.
|
||||
* @type {DataModelValidationFailure}
|
||||
*/
|
||||
#failure;
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Retrieve the root failure that caused this error, or a specific sub-failure via a path.
|
||||
* @param {string} [path] The property path to the failure.
|
||||
* @returns {DataModelValidationFailure}
|
||||
*
|
||||
* @example Retrieving a failure.
|
||||
* ```js
|
||||
* const changes = {
|
||||
* "foo.bar": "validValue",
|
||||
* "foo.baz": "invalidValue"
|
||||
* };
|
||||
* try {
|
||||
* doc.validate(expandObject(changes));
|
||||
* } catch ( err ) {
|
||||
* const failure = err.getFailure("foo.baz");
|
||||
* console.log(failure.invalidValue); // "invalidValue"
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
getFailure(path) {
|
||||
if ( !this.#failure ) return;
|
||||
if ( !path ) return this.#failure;
|
||||
let failure = this.#failure;
|
||||
for ( const p of path.split(".") ) {
|
||||
if ( !failure ) return;
|
||||
if ( !isEmpty(failure.fields) ) failure = failure.fields[p];
|
||||
else if ( !isEmpty(failure.elements) ) failure = failure.elements.find(e => e.id?.toString() === p);
|
||||
}
|
||||
return failure;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Retrieve a flattened object of all the properties that failed validation as part of this error.
|
||||
* @returns {Record<string, DataModelValidationFailure>}
|
||||
*
|
||||
* @example Removing invalid changes from an update delta.
|
||||
* ```js
|
||||
* const changes = {
|
||||
* "foo.bar": "validValue",
|
||||
* "foo.baz": "invalidValue"
|
||||
* };
|
||||
* try {
|
||||
* doc.validate(expandObject(changes));
|
||||
* } catch ( err ) {
|
||||
* const failures = err.getAllFailures();
|
||||
* if ( failures ) {
|
||||
* for ( const prop in failures ) delete changes[prop];
|
||||
* doc.validate(expandObject(changes));
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
getAllFailures() {
|
||||
if ( !this.#failure || this.#failure.isEmpty() ) return;
|
||||
return DataModelValidationError.#aggregateFailures(this.#failure);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Log the validation error as a table.
|
||||
*/
|
||||
logAsTable() {
|
||||
const failures = this.getAllFailures();
|
||||
if ( isEmpty(failures) ) return;
|
||||
console.table(Object.entries(failures).reduce((table, [p, failure]) => {
|
||||
table[p] = failure.toObject();
|
||||
return table;
|
||||
}, {}));
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Generate a nested tree view of the error as an HTML string.
|
||||
* @returns {string}
|
||||
*/
|
||||
asHTML() {
|
||||
const renderFailureNode = failure => {
|
||||
if ( failure.isEmpty() ) return `<li>${failure.message || ""}</li>`;
|
||||
const nodes = [];
|
||||
for ( const [field, subFailure] of Object.entries(failure.fields) ) {
|
||||
nodes.push(`<li><details><summary>${field}</summary><ul>${renderFailureNode(subFailure)}</ul></details></li>`);
|
||||
}
|
||||
for ( const element of failure.elements ) {
|
||||
const name = element.name || element.id;
|
||||
const html = `
|
||||
<li><details><summary>${name}</summary><ul>${renderFailureNode(element.failure)}</ul></details></li>
|
||||
`;
|
||||
nodes.push(html);
|
||||
}
|
||||
return nodes.join("");
|
||||
};
|
||||
return `<ul class="summary-tree">${renderFailureNode(this.#failure)}</ul>`;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Collect nested failures into an aggregate object.
|
||||
* @param {DataModelValidationFailure} failure The failure.
|
||||
* @returns {DataModelValidationFailure|Record<string, DataModelValidationFailure>} Returns the failure at the leaf of the
|
||||
* tree, otherwise an object of
|
||||
* sub-failures.
|
||||
*/
|
||||
static #aggregateFailures(failure) {
|
||||
if ( failure.isEmpty() ) return failure;
|
||||
const failures = {};
|
||||
const recordSubFailures = (field, subFailures) => {
|
||||
if ( subFailures instanceof DataModelValidationFailure ) failures[field] = subFailures;
|
||||
else {
|
||||
for ( const [k, v] of Object.entries(subFailures) ) {
|
||||
failures[`${field}.${k}`] = v;
|
||||
}
|
||||
}
|
||||
};
|
||||
for ( const [field, subFailure] of Object.entries(failure.fields) ) {
|
||||
recordSubFailures(field, DataModelValidationError.#aggregateFailures(subFailure));
|
||||
}
|
||||
for ( const element of failure.elements ) {
|
||||
recordSubFailures(element.id, DataModelValidationError.#aggregateFailures(element.failure));
|
||||
}
|
||||
return failures;
|
||||
}
|
||||
}
|
||||
56
resources/app/common/data/validators.mjs
Normal file
56
resources/app/common/data/validators.mjs
Normal file
@@ -0,0 +1,56 @@
|
||||
/** @module validators */
|
||||
|
||||
/**
|
||||
* Test whether a string is a valid 16 character UID
|
||||
* @param {string} id
|
||||
* @return {boolean}
|
||||
*/
|
||||
export function isValidId(id) {
|
||||
return /^[a-zA-Z0-9]{16}$/.test(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test whether a file path has an extension in a list of provided extensions
|
||||
* @param {string} path
|
||||
* @param {string[]} extensions
|
||||
* @return {boolean}
|
||||
*/
|
||||
export function hasFileExtension(path, extensions) {
|
||||
const xts = extensions.map(ext => `\\.${ext}`).join("|");
|
||||
const rgx = new RegExp(`(${xts})(\\?.*)?$`, "i");
|
||||
return !!path && rgx.test(path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test whether a string data blob contains base64 data, optionally of a specific type or types
|
||||
* @param {string} data The candidate string data
|
||||
* @param {string[]} [types] An array of allowed mime types to test
|
||||
* @return {boolean}
|
||||
*/
|
||||
export function isBase64Data(data, types) {
|
||||
if ( types === undefined ) return /^data:([a-z]+)\/([a-z0-9]+);base64,/.test(data);
|
||||
return types.some(type => data.startsWith(`data:${type};base64,`))
|
||||
}
|
||||
|
||||
/**
|
||||
* Test whether an input represents a valid 6-character color string
|
||||
* @param {string} color The input string to test
|
||||
* @return {boolean} Is the string a valid color?
|
||||
*/
|
||||
export function isColorString(color) {
|
||||
return /^#[0-9A-Fa-f]{6}$/.test(color);
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert that the given value parses as a valid JSON string
|
||||
* @param {string} val The value to test
|
||||
* @return {boolean} Is the String valid JSON?
|
||||
*/
|
||||
export function isJSON(val) {
|
||||
try {
|
||||
JSON.parse(val);
|
||||
return true;
|
||||
} catch(err) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user