define([
"common/DataONEXmlUtilities",
"common/XMLUtilities",
"common/XMLTypes",
"common/DateUtilities",
"common/ValueUtilities",
"common/ValidationUtilities",
"models/sysmeta/Checksum",
"models/sysmeta/AccessPolicy",
"models/sysmeta/ReplicationPolicy",
"models/sysmeta/ReplicaList",
"models/sysmeta/MediaType",
"models/sysmeta/SysMetaSchema",
], (
DataONEXmlUtilities,
XMLUtilities,
XMLTypes,
DateUtilities,
ValueUtilities,
ValidationUtilities,
Checksum,
AccessPolicy,
ReplicationPolicy,
ReplicaList,
MediaType,
SysMetaSchema,
) => {
const XMLNS_NAMESPACE_URI = "http://www.w3.org/2000/xmlns/";
const CANONICAL_XML_DECLARATION =
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
const {
CANONICAL_XML,
NODE_ORDER,
PROPERTY_ORDER,
SIMPLE_FIELD_DEFINITIONS,
DEFAULT_SIMPLE_FIELD_VALUES,
OPTIONAL_PROPERTIES,
getPropertyName,
} = SysMetaSchema;
const { normalizeText, isPlainObject } = ValueUtilities;
const {
createValidationError,
createValidationIssue,
createValidationException,
} = ValidationUtilities;
const SIMPLE_TYPE_BY_FIELD = Object.fromEntries(
SIMPLE_FIELD_DEFINITIONS.map(({ field, type }) => [field, type]),
);
const OPTIONAL_PROPERTY_SET = new Set(OPTIONAL_PROPERTIES);
const MESSAGES = {
VALIDATION_ERROR:
"System Metadata validation failed. Please contact support for assistance.",
};
/**
* Typed DataONE System Metadata domain model with direct root properties,
* owned child domains, tolerant XML parsing, and canonical v2 XML
* serialization.
*
* Parsing silently repairs harmless XML shape issues such as missing
* namespaces, unknown child elements, out-of-order known children, and
* duplicate singleton elements where the last value wins. `parseWarnings`
* is only populated when parsing had to drop invalid optional content.
* @property {number|null} serialVersion Optional sysmeta serial version.
* @property {string|null} identifier DataONE identifier.
* @property {string|null} formatId DataONE format identifier.
* @property {number|null} size Object size in bytes.
* @property {string|null} submitter Optional submitter subject.
* @property {string|null} rightsHolder Rights holder subject.
* @property {string|null} obsoletes PID obsoleted by this object.
* @property {string|null} obsoletedBy PID that obsoletes this object.
* @property {boolean|null} archived Archived flag.
* @property {Date|null} dateUploaded Upload timestamp.
* @property {Date|null} dateSysMetadataModified Sysmeta-modified timestamp.
* @property {string|null} originMemberNode Origin member node.
* @property {string|null} authoritativeMemberNode Authoritative member node.
* @property {string|null} seriesId Optional series identifier.
* @property {string|null} fileName Optional file name.
* @property {Checksum} checksum Owned checksum value object.
* @property {AccessPolicy} accessPolicy Owned access-policy collection.
* @property {ReplicationPolicy} replicationPolicy Owned replication policy.
* @property {ReplicaList} replicas Owned replica collection.
* @property {MediaType} mediaType Owned media-type value object.
* @property {Array<object>} parseWarnings Lossy parse warnings from the most
* recent successful XML parse.
* @class SystemMetadata
* @since 2.34.0
*/
class SystemMetadata {
/**
* Create a SystemMetadata instance.
* @param {object} [data] Initial system metadata values.
*/
constructor(data = {}) {
this.reset();
const values = data instanceof SystemMetadata ? data.toJSON() : data;
const normalizedValues = SystemMetadata.normalizeValues(values);
Object.assign(this, normalizedValues);
}
/**
* Parse and normalize a System Metadata XML document onto this instance.
* Harmless XML cleanup such as missing namespaces, unknown child elements,
* out-of-order known children, and duplicate singleton elements is
* repaired silently. `parseWarnings` is only populated when parsing had to
* drop invalid optional content.
* @param {string} xmlString System Metadata XML string to parse.
* @returns {SystemMetadata} The same instance with parsed values.
* @throws {Error} Throws when the XML is malformed, is a DataONE error
* document, has the wrong root element, or is missing required core data
* after recovery.
*/
parse(xmlString) {
try {
const context = "SystemMetadata XML";
const xmlDoc = DataONEXmlUtilities.parseRequiredDocument(
xmlString,
context,
);
const root = XMLUtilities.requireDocumentElement(
xmlDoc,
"systemMetadata",
context,
);
const parseWarnings = [];
const parsedValues = {};
// Parse simple text, integer, boolean, and date fields with simple parsers.
SIMPLE_FIELD_DEFINITIONS.forEach(({ field }) => {
const el = XMLUtilities.findLastDirectChildElement(root, field);
const text = el?.textContent;
parsedValues[field] =
(field === "fileName" ? text : text?.trim()) || null;
});
// Parse complex fields
const COMPLEX_MAP = {
checksum: Checksum,
accessPolicy: AccessPolicy,
replicationPolicy: ReplicationPolicy,
mediaType: MediaType,
};
Object.entries(COMPLEX_MAP).forEach(([field, Type]) => {
parsedValues[field] = Type.parse(
XMLUtilities.findLastDirectChildElement(root, field),
parseWarnings,
field,
);
});
// Replicas are a special case since they can be multiple but still have
// a singleton field name
parsedValues.replicas = ReplicaList.parse(
XMLUtilities.findDirectChildElements(root, "replica"),
parseWarnings,
"replicas",
);
// To avoid failures in reading and editing irregular XML, attempt to
// recover from any parse issues
const { normalizedValues, fatalIssues } =
SystemMetadata.repairParsedValues(parsedValues, parseWarnings);
if (fatalIssues.length) {
throw createValidationException(
"SystemMetadata XML failed validation",
fatalIssues,
);
}
Object.assign(this, normalizedValues);
this.parseWarnings = parseWarnings;
return this;
} catch (error) {
this.reset();
this.parseWarnings = [];
throw error;
}
}
/**
* Reset all fields to default values.
* @since 2.39.0
*/
reset() {
Object.assign(this, SystemMetadata.getDefaults());
this.parseWarnings = [];
}
/**
* Reset a single field to its default in-place.
* @param {object} values Values object to reset a field on.
* @param {string} field Field name to reset to default.
* @since 2.39.0
*/
static resetFieldToDefault(values, field) {
const defaults = SystemMetadata.getDefaults();
/* eslint-disable-next-line no-param-reassign */
values[field] = defaults[field] ?? null;
}
/**
* Get default values for all sysMeta fields.
* @returns {object} Default values object.
* @since 2.39.0
*/
static getDefaults() {
return {
...DEFAULT_SIMPLE_FIELD_VALUES,
checksum: new Checksum(),
accessPolicy: new AccessPolicy(),
replicationPolicy: new ReplicationPolicy(),
replicas: new ReplicaList(),
mediaType: new MediaType(),
};
}
/**
* Validate the current System Metadata state.
* @returns {Array<object>} Validation issues for invalid fields.
* @since 2.39.0
*/
validate() {
return SystemMetadata.validateValues(this);
}
/**
* Validate values without requiring mutation of a caller-owned object.
* @param {object} values Candidate System Metadata values.
* @returns {Array<object>} Validation issues.
* @since 2.39.0
*/
static validateValues(values) {
const normalized = SystemMetadata.normalizeValues(values);
const issues = SystemMetadata.validateSimpleFields(normalized);
issues.push(...normalized.checksum.validate("checksum"));
issues.push(...normalized.accessPolicy.validate("accessPolicy"));
if (normalized.replicationPolicy.hasValues()) {
issues.push(
...normalized.replicationPolicy.validate("replicationPolicy"),
);
}
if (normalized.replicas.length > 0) {
issues.push(...normalized.replicas.validate("replicas"));
}
if (!normalized.mediaType.isEmpty()) {
issues.push(...normalized.mediaType.validate("mediaType"));
}
if (
normalized.seriesId &&
normalized.identifier &&
normalized.seriesId === normalized.identifier
) {
issues.push(
createValidationIssue({
field: "seriesId",
message: "seriesId must not be identical to identifier.",
}),
);
}
return issues;
}
/**
* Serialize the current System Metadata state to canonical v2 XML.
* @param {object} [options] Serialization options.
* @param {boolean} [options.validate] Whether to validate before
* serializing.
* @returns {string} Canonical v2 XML serialization.
* @throws {Error} Throws when validation is enabled and the model is
* invalid.
* @since 2.39.0
*/
serialize({ validate = true } = {}) {
if (validate) {
const validationIssues = this.validate();
if (validationIssues.length) {
// Until we have a better mechanism to display validation issues to
// the user, log them for debugging.
/* eslint-disable no-console */
console.error("SysMeta validation issues:", validationIssues, this);
throw createValidationException(
MESSAGES.VALIDATION_ERROR,
validationIssues,
);
}
}
const normalized = SystemMetadata.normalizeValues(this.toObject());
const doc = document.implementation.createDocument(
CANONICAL_XML.rootNamespaceUri,
CANONICAL_XML.rootQualifiedName,
null,
);
const root = doc.documentElement;
CANONICAL_XML.namespaceAttributes.forEach(({ name, value }) => {
root.setAttributeNS(XMLNS_NAMESPACE_URI, name, value);
});
// Serialize dates as-is. dateUploaded is immutable once set (overwriting
// it makes Metacat reject updateSystemMetadata with a 400), and the
// server assigns the authoritative dateUploaded/dateSysMetadataModified on
// write, so new objects intentionally serialize a null dateUploaded.
NODE_ORDER.forEach((field) => {
switch (field) {
case "fileName":
XMLUtilities.appendTextElement(
doc,
root,
field,
normalized.fileName,
);
return;
case "checksum":
case "mediaType":
case "replicationPolicy":
case "accessPolicy": {
const el = normalized[field]?.toElement?.(doc);
if (el) root.appendChild(el);
return;
}
case "replica":
normalized.replicas.forEach((replica) => {
const el = replica.toElement(doc);
if (el) root.appendChild(el);
});
return;
default:
if (SIMPLE_TYPE_BY_FIELD[field]) {
XMLUtilities.appendTextElement(
doc,
root,
field,
XMLTypes[SIMPLE_TYPE_BY_FIELD[field]]?.serialize?.(
normalized[field],
) ?? null,
);
}
}
});
return `${CANONICAL_XML_DECLARATION}\n${new XMLSerializer().serializeToString(doc)}`;
}
/**
* Return values for all sysMeta fields in a plain object.
* @returns {object} Plain snapshot of the current state.
* @since 2.39.0
*/
toObject() {
return Object.fromEntries(
PROPERTY_ORDER.map((field) => [field, this[field]]),
);
}
/**
* Return a JSON-safe snapshot of the current normalized state.
* @returns {object} Plain system metadata JSON.
*/
toJSON() {
const values = SystemMetadata.normalizeValues(this.toObject());
const checksum = values.checksum.toJSON() || {};
return {
...values,
checksum: checksum.value || null,
checksumAlgorithm: checksum.algorithm || null,
accessPolicy: values.accessPolicy.toJSON(),
replicationPolicy: values.replicationPolicy.hasValues()
? values.replicationPolicy.toJSON()
: null,
dateUploaded: DateUtilities.toISOString(values.dateUploaded) || null,
dateSysMetadataModified:
DateUtilities.toISOString(values.dateSysMetadataModified) || null,
replicas: values.replicas.toJSON(),
mediaType: values.mediaType.isEmpty()
? null
: values.mediaType.toJSON(),
};
}
/**
* Create an independent copy of this System Metadata.
* @returns {SystemMetadata} Cloned System Metadata
* @since 2.39.0
*/
clone() {
return new SystemMetadata(this.toJSON());
}
/**
* Create a new SystemMetadata instance from XML.
* @param {string} xmlString System Metadata XML string.
* @returns {SystemMetadata} Parsed System Metadata instance.
* @since 2.39.0
*/
static fromXml(xmlString) {
return new SystemMetadata().parse(xmlString);
}
/**
* Normalize this instance's values in-place
* @returns {SystemMetadata} This instance with normalized values.
* @since 2.39.0
*/
normalize() {
const normalizedValues = SystemMetadata.normalizeValues(this.toObject());
Object.assign(this, normalizedValues);
return this;
}
/**
* Normalize sysMeta values, applying defaults and type conversions when
* necessary.
* @param {object} values An object with sysMeta fields to normalize.
* @returns {object} Normalized values object.
* @since 2.39.0
*/
static normalizeValues(values) {
const source = isPlainObject(values) ? values : {};
// Start with defaults
const normalized = this.getDefaults();
// Normalize simple text, integer, boolean, and date fields with simple
// normalizers.
SIMPLE_FIELD_DEFINITIONS.forEach(({ field, type }) => {
const rawValue =
source[field] !== undefined
? source[field]
: DEFAULT_SIMPLE_FIELD_VALUES[field];
// Filenames identify exact uploaded names; whitespace is significant.
if (field === "fileName" && typeof rawValue === "string") {
normalized[field] = rawValue;
return;
}
const normalizer = XMLTypes[type]?.normalize;
normalized[field] =
typeof normalizer === "function" ? normalizer(rawValue) : rawValue;
});
Object.entries({
checksum: Checksum,
accessPolicy: AccessPolicy,
replicationPolicy: ReplicationPolicy,
replicas: ReplicaList,
mediaType: MediaType,
}).forEach(([field, Type]) => {
const val =
field === "checksum" &&
!(source.checksum instanceof Checksum) &&
!isPlainObject(source.checksum)
? {
value: source.checksum,
algorithm: source.checksumAlgorithm,
}
: source[field];
normalized[field] = Type.fromValue(val).normalize();
});
return normalized;
}
/**
* Validate simple text, integer, boolean, and date fields.
* @param {object} values Normalized values object.
* @returns {Array<object>} Validation issues for simple fields.
* @private
* @since 2.39.0
*/
static validateSimpleFields(values) {
const issues = [];
SIMPLE_FIELD_DEFINITIONS.forEach((definition) => {
const { field, type, requiredNonEmpty, optionalNonEmpty } = definition;
const value = values[field];
const validate = XMLTypes[type]?.validate;
if (typeof validate !== "function") return;
issues.push(
...validate(value, {
field,
required: requiredNonEmpty || field === "size",
nonEmptyWhenPresent: optionalNonEmpty,
}),
);
});
return issues;
}
/**
* Attempt to repair parsed values by resetting recoverable fields to
* defaults and collecting fatal issues for non-recoverable fields. Invalid
* content in optional fields is recoverable, but missing or invalid
* required fields are fatal. Repaired fields are tracked to avoid duplicate
* repairs and warnings when multiple issues affect the same
* property.
* @param {object} parsedValues Parsed candidate values.
* @param {Array<object>} parseWarnings Parse warnings collected so far.
* @returns {{normalizedValues: object, fatalIssues: Array<object>}}
* Normalized values plus any fatal issues.
* @private
* @since 2.39.0
*/
static repairParsedValues(parsedValues, parseWarnings) {
const normalizedValues = SystemMetadata.normalizeValues(parsedValues);
const fatalIssues = [];
const repairedFields = new Set();
SystemMetadata.validateValues(normalizedValues).forEach((issue) => {
const fieldName =
normalizeText(issue.field)?.split(/[.[]/, 1)[0] || null;
const property = getPropertyName(fieldName);
if (!property || !OPTIONAL_PROPERTY_SET.has(property)) {
fatalIssues.push(issue);
return;
}
if (repairedFields.has(property)) {
return;
}
repairedFields.add(property);
SystemMetadata.resetFieldToDefault(normalizedValues, property);
parseWarnings.push(
createValidationError(
property,
`Ignored invalid ${property} content while parsing system metadata.`,
),
);
});
return { normalizedValues, fatalIssues };
}
}
SystemMetadata.MESSAGES = MESSAGES;
return SystemMetadata;
});