This commit is contained in:
2025-01-04 00:34:03 +01:00
parent 41829408dc
commit 0ca14bbc19
18111 changed files with 1871397 additions and 0 deletions

View 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
}

File diff suppressed because it is too large Load Diff

View 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";

View 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;
}
}

View 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;
}
}