"use strict";
define([
"backbone",
"common/ErrorUtilities",
"common/ValueUtilities",
"common/XMLUtilities",
"models/dataONEServices/AuthorizationService",
"models/dataONEServices/ObjectService",
"models/dataONEServices/SysMetaService",
"models/resourceMap/ResourceMap",
"models/sysmeta/SystemMetadata",
"models/metadata/ScienceMetadata",
"models/metadata/eml211/EML211",
"models/viewService/ViewServiceDoc",
"collections/ObjectFormats",
], (
Backbone,
ErrorUtilities,
Values,
XMLUtilities,
AuthorizationService,
ObjectService,
SysMetaService,
ResourceMap,
SystemMetadata,
ScienceMetadata,
EML211,
ViewServiceDoc,
ObjectFormats,
) => {
const REMOTE_STATES = Object.freeze({
UNKNOWN: "unknown",
PENDING: "pending",
UPLOADING: "uploading",
UPLOADED: "uploaded",
FAILED: "failed",
AMBIGUOUS: "ambiguous",
});
const REQUIRED_OPERATIONS = Object.freeze({
NONE: "none",
CREATE: "create",
UPDATE: "update",
UPDATE_SYSTEM_METADATA: "updateSystemMetadata",
REMOVE: "remove",
});
const EDITABLE_STATE_FIELDS = new Set([
"pid",
"remotePid",
"aggregatedPid",
"sysMeta",
"remoteSysMeta",
"contentDirty",
"sysMetaDirty",
"accessPolicyDirty",
"removed",
"remoteState",
"lastUploadError",
]);
const RESOURCE_MAP_GRAPH_FIELDS = Object.freeze([
"documents",
"isDocumentedBy",
"atLocations",
]);
// These values describe package relationships, but they are not editable
// member state. Once a parsed ResourceMap is loaded, DataPackage refreshes
// the graph projection fields from the ResourceMap state. The resourceMap
// field remains parent-package/discovery metadata from Solr or resource map
// load seeding, not current graph membership.
const RESOURCE_MAP_RELATIONSHIP_FIELDS = new Set([
...RESOURCE_MAP_GRAPH_FIELDS,
"resourceMap",
]);
const MUTABLE_SYSTEM_METADATA_FIELDS = [
"rightsHolder",
"accessPolicy",
"replicationPolicy",
"archived",
"fileName",
];
const FALLBACK_OBJECT_FORMATS = new ObjectFormats();
/**
* Store one data package member's manifest, editable, and upload state.
* @class DataPackageMember
* @classcategory Models/DataPackage
* @since 2.39.0
*/
class DataPackageMember {
/**
* Create a package member.
* @param {object} [info] Member fields and editable state
* @param {ObjectFormats} [info.objectFormats] Format collection used for
* classification
*/
constructor(info = {}) {
this.pid = Values.normalizeText(info.pid || info.id || info.identifier);
this.events = { ...Backbone.Events };
this.sources = [];
this.addSources(info.sources);
// Package members receive their collection's shared formats. Standalone
// members use the built-in formats without starting a remote request.
this.objectFormats = info.objectFormats || FALLBACK_OBJECT_FORMATS;
// Preserve Solr, ResourceMap, and View Service fields that package
// table, viewer, and provenance paths may read directly.
Object.entries(info).forEach(([key, value]) => {
if (
key === "id" ||
key === "identifier" ||
EDITABLE_STATE_FIELDS.has(key)
) {
return; // initialized below
}
if (key === "events" || key === "sources" || key === "objectFormats") {
return; // handled above
}
if (key === "isPublic") {
// Preserve Solr's snapshot without shadowing the isPublic() method.
this.indexedIsPublic = value;
return;
}
this[key] = value;
});
this.initializeEditableState(info);
}
/**
* Return a member instance for a member or plain object.
* @param {DataPackageMember|object} [value] Member value
* @param {object} [options] Conversion options
* @param {string[]} [options.sources] Manifest sources to add
* @returns {DataPackageMember} Member instance
*/
static from(value = {}, options = {}) {
if (value instanceof DataPackageMember) {
if (options.sources) {
value.addSources(options.sources);
}
return value;
}
const newValues = { ...value };
if (options.sources) {
newValues.sources = options.sources;
}
return new DataPackageMember(newValues);
}
/**
* Add manifest sources without duplicates.
* @param {string|string[]} sources Sources to add
* @returns {void}
*/
addSources(sources) {
if (!sources) return;
const existingSources = Array.isArray(this.sources) ? this.sources : [];
const newSources = Values.listify(sources);
this.sources = Array.from(new Set([...existingSources, ...newSources]));
}
/**
* Download and parse this member's object into a typed model, stored on
* `this.objectModel`:
* - metadata → an EML211 (EML) or ScienceMetadata (other format) model
* - resourceMap → a ResourceMap model
* - data/unknown → no model (data is fetched on demand for preview/download)
* @param {object} [options] Fetch options
* @param {AbortSignal} [options.signal] Signal used to cancel downloads
* @param {ObjectService} [options.objectService] Service used to download a
* Resource Map
* @param {string} [options.resolveServiceUrl] Resolver URL for Resource Map
* identities, overriding the ObjectService setting
* @returns {Promise<object|null>} The parsed object model, or null
*/
async fetchObject(options = {}) {
const { pid } = this;
if (this.isMetadata()) {
this.objectModel = await this.fetchMetadataModel(options);
return this.objectModel;
}
if (this.isResourceMap()) {
const { objectService } = options;
const downloadOptions = {};
if (options.signal) downloadOptions.signal = options.signal;
const blob = await objectService.download(pid, downloadOptions);
this.rawData = await blob.text();
// Keep the MN download URL separate from the CN URLs used in RDF.
this.objectModel = ResourceMap.fromXml(pid, this.rawData, {
resolveServiceUrl:
options.resolveServiceUrl ?? objectService.resolveServiceUrl,
objectServiceUrl: objectService.readBaseUrl,
});
return this.objectModel;
}
// Data objects are downloaded on demand for preview/download rather than
// parsed into a model, so there is nothing to build here.
return this.objectModel || null;
}
/**
* Build this member's typed metadata model and download its content. The
* member already knows its formatId from the manifest, so the typed model
* is constructed without a Solr round trip or ScienceMetadata→EML
* "replace" handshake. The model is created unsynced so fetch population
* is not trickled up as a user edit; it flips `synced` to true on its own
* "sync" event.
* @param {object} [options] Fetch options
* @param {AbortSignal} [options.signal] Signal used to cancel fetches
* @returns {Promise<EML211|ScienceMetadata>} The fetched metadata model
*/
async fetchMetadataModel(options = {}) {
const { pid } = this;
const formatId = this.getFormatId() || undefined;
const model = this.isEML()
? new EML211(
{ id: pid, formatId, synced: false },
{ packageEvents: this._packageEvents },
)
: new ScienceMetadata(
{ id: pid, formatId, synced: false },
{ packageEvents: this._packageEvents },
);
// Wait on the model's events, not fetch() callbacks: EML211.fetch also
// runs a separate system-metadata fetch that invokes the success/error
// callbacks, but only the document load fires "sync" (sysmeta fires
// "sysMetaUpdated"). This ties resolution to the document itself.
await new Promise((resolve, reject) => {
model.once("sync", resolve);
model.once("error", (_model, err) =>
reject(
err instanceof Error
? err
: new Error(`Failed to fetch metadata object ${pid}`),
),
);
const fetchOptions = options.signal
? { signal: options.signal }
: undefined;
model.fetch(fetchOptions);
});
// Preserve the raw XML for the upload fallback (objectModel.serialize() is
// the primary serializer; rawData is only the fallback).
const objectXML = model.get?.("objectXML");
if (objectXML) this.rawData = objectXML;
return model;
}
/**
* Download system metadata and update this member's remote baseline.
* @param {object} [options] Download and service options
* @param {SysMetaService} [options.sysMetaService] Service instance
* @param {object} [options.sysMetaServiceOptions] Service constructor options
* @returns {Promise<SystemMetadata>} Downloaded system metadata
*/
async fetchSysMeta(options = {}) {
const pid = this.remotePid || this.aggregatedPid || this.pid;
const {
sysMetaService = null,
sysMetaServiceOptions = {},
...downloadOptions
} = options;
const service =
sysMetaService || new SysMetaService(sysMetaServiceOptions);
const sysMeta = await service.download(pid, downloadOptions);
this.sysMetaReadDenied = false;
if (!this.remotePid && this.aggregatedPid) {
this.remotePid = this.aggregatedPid;
}
this._rebaseSystemMetadata(sysMeta);
const sysMetaMember = new DataPackageMember({
...sysMeta.toJSON(),
fileName: this.sysMeta.fileName,
sources: ["sysMeta"],
});
if (this.pid === sysMetaMember.pid) {
this.merge(sysMetaMember);
}
return this.sysMeta;
}
/**
* Replace the remote baseline while retaining planned mutable changes.
* @param {SystemMetadata|object} remoteSysMeta Fresh remote System Metadata
* @param {object} [options] Rebase options
* @param {boolean} [options.updateRemoteBaseline] Whether the fresh source
* replaces the member's confirmed remote baseline
* @param {SystemMetadata|object} [options.localEditBaseline] Baseline used
* to distinguish explicit local edits from prior source values
* @returns {SystemMetadata} Rebased desired System Metadata
* @private
*/
_rebaseSystemMetadata(
remoteSysMeta,
{ updateRemoteBaseline = true, localEditBaseline = null } = {},
) {
const fresh =
remoteSysMeta instanceof SystemMetadata
? remoteSysMeta
: new SystemMetadata(remoteSysMeta);
const previousSysMeta = localEditBaseline || this.remoteSysMeta;
const previousValues =
previousSysMeta?.toJSON?.() || previousSysMeta || {};
const desiredValues = this.sysMeta?.toJSON?.() || {};
const values = fresh.toJSON();
if (this.sysMetaDirty && this.sysMeta) {
MUTABLE_SYSTEM_METADATA_FIELDS.forEach((field) => {
if (
!previousSysMeta ||
(field === "accessPolicy" && this.accessPolicyDirty) ||
!Values.deepEqual(previousValues[field], desiredValues[field])
) {
values[field] = desiredValues[field];
}
});
}
if (updateRemoteBaseline) {
this.remoteSysMeta = fresh.clone();
this._remoteSysMetaDownloaded = true;
this._remoteSysMetaParseWarnings = [...(fresh.parseWarnings || [])];
} else if (this._replacementSourcePid) {
// A retargeted replacement inherits from another remote object. Keep
// that source separate from this member's own confirmed remote baseline.
this._replacementSourceSysMeta = fresh.clone();
}
this.sysMeta = new SystemMetadata(values);
this.sysMetaMissing = false;
return this.sysMeta;
}
/**
* Add information inferred from a rendered metadata document.
* @param {object} viewInfo Plain entity summary from
* ViewServiceDoc
* @returns {DataPackageMember} This member
* @throws {Error} When the view information belongs to another PID
*/
addViewInfo(viewInfo = {}) {
const info = DataPackageMember.normalizeViewInfo(viewInfo);
if (!info.pid) return this;
if (this.pid && info.pid !== this.pid) {
throw new Error("Cannot add view info for a different member PID");
}
this.viewServiceEntity = {
...(this.viewServiceEntity || {}),
...info,
};
this.addSources(["viewService"]);
if (!this.fileName && info.fileName) this.fileName = info.fileName;
if (!this.title && info.entityName) this.title = info.entityName;
return this;
}
/**
* Normalize a rendered metadata entity summary for matching and merging.
* Identifiers follow the same conventions as {@link ViewServiceDoc} so the
* summary can be matched against member PIDs.
* @param {object} [viewInfo] Entity summary from ViewServiceDoc
* @returns {object} Normalized summary with a normalized `pid` (or null)
*/
static normalizeViewInfo(viewInfo = {}) {
return {
...viewInfo,
pid: ViewServiceDoc.normalizeIdentifier(
viewInfo.pid || viewInfo.identifier || viewInfo.id,
),
fileName:
Values.normalizeText(viewInfo.fileName || viewInfo.objectName) ||
null,
entityName: Values.normalizeText(viewInfo.entityName) || null,
objectUrl: Values.normalizeText(viewInfo.objectUrl) || null,
};
}
/**
* Merge enrichment data without overwriting editable lifecycle state.
* @param {DataPackageMember|object} packageMember Member data to merge
* @returns {DataPackageMember} This member
* @throws {Error} When the incoming member has a different PID
*/
merge(packageMember) {
const incomingMember = DataPackageMember.from(packageMember);
const newData = incomingMember.toJSON();
if (this.pid !== newData.pid) {
throw new Error("Cannot merge DataPackageMember with different pid");
}
const incomingFromParsedResourceMap =
incomingMember.sources.includes("resourceMap");
Object.entries(newData).forEach(([key, value]) => {
if (key === "pid") return; // never merge pid, it should be the same
if (key === "sources") {
this.addSources(value);
return;
}
if (EDITABLE_STATE_FIELDS.has(key)) return;
if (RESOURCE_MAP_RELATIONSHIP_FIELDS.has(key)) {
if (incomingFromParsedResourceMap) {
this[key] = Array.isArray(value) ? [...value] : value;
}
return;
}
if (Array.isArray(value)) {
const existingArray = Array.isArray(this[key]) ? this[key] : [];
this[key] = Array.from(new Set([...existingArray, ...value]));
} else if (value && typeof value === "object") {
this[key] = { ...(this[key] || {}), ...value };
} else if (value !== undefined && value !== null) {
// For primitive values, overwrite if new value is not undefined
this[key] = value;
} // else keep existing value
});
return this;
}
/**
* Get the member ready for editing by initializing fields that are not set
* by the manifest or Solr. It does not overwrite existing editable state.
* @param {object} [state] Editable state values
* @returns {DataPackageMember} This member
*/
initializeEditableState(state = {}) {
this.remotePid = Values.normalizeText(state.remotePid) || null;
this.aggregatedPid = Values.normalizeText(state.aggregatedPid) || null;
this.sysMeta = state.sysMeta ? new SystemMetadata(state.sysMeta) : null;
this.remoteSysMeta = state.remoteSysMeta
? new SystemMetadata(state.remoteSysMeta)
: null;
this._remoteSysMetaDownloaded = false;
this._remoteSysMetaParseWarnings = [];
this.contentDirty = state.contentDirty === true;
this.sysMetaDirty = state.sysMetaDirty === true;
this.accessPolicyDirty = state.accessPolicyDirty === true;
this.removed = state.removed === true;
this.remoteState =
state.remoteState ||
(this.remotePid ? REMOTE_STATES.UPLOADED : REMOTE_STATES.UNKNOWN);
this.lastUploadError = state.lastUploadError || null;
this._replacementSourcePid = null;
this._replacementSourceSysMeta = null;
return this;
}
/**
* Change the PID desired by the editable package without changing the
* confirmed remote or last aggregated PID.
* @param {string} pid Desired PID
* @returns {DataPackageMember} This member
* @throws {Error} When an ambiguous remote write is unresolved
*/
setDesiredPid(pid) {
if (this.remoteState === REMOTE_STATES.AMBIGUOUS) {
throw new Error(
"Resolve the ambiguous remote write before changing the desired PID",
);
}
const desiredPid = Values.requireNonEmptyString(
pid,
"DataPackageMember.setDesiredPid requires a PID",
);
this.pid = desiredPid;
if (desiredPid !== this.remotePid) this.contentDirty = true;
this.removed = false;
if (this.contentDirty) this.remoteState = REMOTE_STATES.PENDING;
return this;
}
/**
* Attach desired object bytes.
* @param {Blob|File} file File or Blob to upload
* @returns {DataPackageMember} This member
* @throws {Error} When the write is ambiguous or the file is invalid
*/
setLocalFile(file) {
if (this.remoteState === REMOTE_STATES.AMBIGUOUS) {
throw new Error(
"Resolve the ambiguous remote write before changing local content",
);
}
DataPackageMember.validateLocalFile(file);
this.uploadFile = file;
this.contentDirty = true;
this.removed = false;
this.remoteState = REMOTE_STATES.PENDING;
if (typeof file.size === "number") this.size = file.size;
if (file.name) this.fileName = file.name;
this.sysMeta?.checksum.clear();
this.checksum = null;
this.checksumAlgorithm = null;
return this;
}
/**
* Validate file content before allocating identifiers or editing state.
* @param {Blob|File} file File or Blob to validate
* @returns {Blob|File} Validated file
* @throws {Error} When the value is not a non empty Blob or File
*/
static validateLocalFile(file) {
if (!(file instanceof Blob)) {
throw new Error(
"DataPackageMember.setLocalFile requires a Blob or File",
);
}
if (file.size === 0) {
throw new Error("Cannot upload an empty file");
}
return file;
}
/**
* Build fresh System Metadata for a create or content update.
* @param {object} [defaults] Values not already present on the member
* @param {boolean} [defaults.useBlobMediaType] Whether to infer mediaType
* from generated Blob content
* @param {SystemMetadata|object} [desiredSystemMetadata] Prepared desired
* values to use instead of the member's stored source baseline
* @returns {Promise<SystemMetadata>} Desired System Metadata
* @throws {Error} When object content is missing or empty
*/
async buildObjectSystemMetadata(
defaults = {},
desiredSystemMetadata = null,
) {
let blob = this.uploadFile;
if (!(blob instanceof Blob) && this.objectModel?.serialize) {
// Serialize at the desired PID so the embedded identifier (for example
// an EML packageId) matches the upload, without mutating the model.
const serialized = await this.objectModel.serialize({
packageId: this.pid,
});
blob =
serialized instanceof Blob
? serialized
: new Blob([serialized], { type: defaults.contentType || "" });
}
if (!(blob instanceof Blob) && this.rawData !== undefined) {
blob =
this.rawData instanceof Blob
? this.rawData
: new Blob([this.rawData], { type: defaults.contentType || "" });
}
if (!(blob instanceof Blob)) {
throw new Error("Cannot build System Metadata without object content");
}
if (blob.size === 0) {
throw new Error("Cannot upload an empty object");
}
const checksum = await Values.calculateBlobChecksum(blob, {
algorithm: defaults.checksumAlgorithm || "MD5",
signal: defaults.signal,
onProgress: defaults.onProgress,
});
const replacementSourceValues =
this._replacementSourceSysMeta?.toJSON?.() ||
this._replacementSourceSysMeta ||
null;
const preparedValues =
desiredSystemMetadata?.toJSON?.() || desiredSystemMetadata || null;
const desiredValues =
preparedValues ||
replacementSourceValues ||
this.sysMeta?.toJSON() ||
this.remoteSysMeta?.toJSON() ||
this;
const desiredAccessPolicy = desiredValues.accessPolicy;
const hasDesiredAccessPolicy =
desiredAccessPolicy !== undefined && desiredAccessPolicy !== null;
const accessPolicyWasExplicitlySet =
this.accessPolicyDirty ||
(this.remotePid && hasDesiredAccessPolicy) ||
Boolean(desiredAccessPolicy?.length);
const mediaType =
defaults.mediaType ||
(defaults.useBlobMediaType === false
? desiredValues.mediaType || null
: blob.type || desiredValues.mediaType || null);
const replacementFileNameChanged =
this._replacementDisplay &&
this.fileName !== this._replacementDisplay.fileName;
const fileName =
preparedValues && !replacementFileNameChanged
? preparedValues.fileName
: this.fileName || desiredValues.fileName;
// Selected files can change format, while the existing format breaks
// ties between ambiguous signals. Generated metadata Blobs retain their
// explicit DataONE format ID.
const formatId =
blob instanceof File
? this.objectFormats.getFormatId({
existingFormatId: desiredValues.formatId || this.formatId,
filename: blob.name,
mediaType: blob.type,
})
: this.formatId || defaults.formatId;
const sysMeta = new SystemMetadata({
identifier: this.pid,
formatId,
size: blob.size,
checksum,
submitter: desiredValues.submitter || defaults.submitter,
rightsHolder: desiredValues.rightsHolder || defaults.rightsHolder,
// Use the default only for a new object with no explicit policy. An
// empty policy on an existing object, or one deliberately made
// private, must remain empty when its content is versioned.
accessPolicy: accessPolicyWasExplicitlySet
? desiredAccessPolicy
: defaults.accessPolicy,
replicationPolicy:
desiredValues.replicationPolicy ?? defaults.replicationPolicy,
obsoletes: this._replacementSourcePid || this.remotePid,
obsoletedBy: null,
archived:
desiredValues.archived === true || defaults.archived === true
? true
: null,
authoritativeMemberNode:
desiredValues.authoritativeMemberNode ||
defaults.authoritativeMemberNode,
seriesId: desiredValues.seriesId || defaults.seriesId,
fileName: fileName || defaults.fileName || this.uploadFile?.name,
mediaType,
});
sysMeta.serialize();
this.uploadFile = blob;
this.sysMeta = sysMeta;
this.sysMetaDirty = true;
this.fileName = sysMeta.fileName || null;
this.size = blob.size;
this.checksum = checksum.value;
this.checksumAlgorithm = checksum.algorithm;
return sysMeta;
}
/**
* Build an update limited to System Metadata from the confirmed remote
* baseline.
* @param {SystemMetadata} [freshRemoteSysMeta] Fresh preflight baseline
* @returns {SystemMetadata} Desired System Metadata
* @throws {Error} When remote or desired system metadata is missing
*/
buildSystemMetadataUpdate(freshRemoteSysMeta = null) {
if (!freshRemoteSysMeta && !this.remoteSysMeta) {
throw new Error(
"Cannot build a System Metadata update without remote System Metadata",
);
}
if (!this.sysMeta) {
throw new Error(
"Cannot build a System Metadata update without desired System Metadata",
);
}
if (freshRemoteSysMeta) {
this._rebaseSystemMetadata(freshRemoteSysMeta);
}
const values = this.remoteSysMeta.toJSON();
const desiredValues = this.sysMeta.toJSON();
// After rebasing, desiredValues contains the fresh remote values plus
// only the mutable fields that were deliberately changed in the draft.
MUTABLE_SYSTEM_METADATA_FIELDS.forEach((field) => {
values[field] = desiredValues[field];
});
const sysMeta = new SystemMetadata(values);
sysMeta.serialize();
this.sysMeta = sysMeta;
this.sysMetaDirty = true;
return sysMeta;
}
/**
* Serialize desired System Metadata for upload.
* @returns {string} Canonical System Metadata XML
* @throws {Error} When system metadata is missing
*/
serializeSystemMetadata() {
if (!this.sysMeta) {
throw new Error("Cannot serialize missing System Metadata");
}
return this.sysMeta.serialize();
}
/**
* Return the fields used to verify an ambiguous object write: enough to
* confirm the target PID exists with the intended bytes.
* @param {object} [options] Verification options
* @param {boolean} [options.includeMutableFields] Include mutable fields
* @returns {object} Verification fields
*/
getSystemMetadataVerificationFields({ includeMutableFields = false } = {}) {
if (!this.sysMeta) return null;
const sysMeta = this.sysMeta.toJSON();
const verification = {
identifier: sysMeta.identifier,
checksum: {
value: sysMeta.checksum,
algorithm: sysMeta.checksumAlgorithm,
},
};
if (includeMutableFields) {
verification.mutableFields = Object.fromEntries(
MUTABLE_SYSTEM_METADATA_FIELDS.map((field) => [
field,
sysMeta[field],
]),
);
}
return verification;
}
/**
* Validate the member's desired object content before upload.
* @returns {object[]} Validation issues (empty when valid)
*/
validateContent() {
if (this.uploadFile instanceof Blob) {
try {
DataPackageMember.validateLocalFile(this.uploadFile);
} catch (error) {
return [{ pid: this.pid, message: error.message }];
}
return [];
}
const model = this.objectModel;
if (model && typeof model.isValid === "function" && !model.isValid()) {
return [
{
pid: this.pid,
message: "Invalid metadata content",
errors: model.validationError || null,
},
];
}
return [];
}
/**
* Serialize the member's desired object content for upload. Cached upload
* bytes are returned as is so the payload matches the computed checksum;
* otherwise the attached object model is serialized at the desired PID.
* @param {object} [options] Serialization options
* @param {string} [options.pid] Desired PID to embed (for example, an EML
* packageId)
* @returns {Promise<Blob|string>} Serialized content
* @throws {Error} When the member has no serializable content
*/
async serializeContent({ pid = this.pid } = {}) {
if (this.uploadFile instanceof Blob) {
return this.uploadFile;
}
const model = this.objectModel;
if (model && typeof model.serialize === "function") {
return model.serialize({ packageId: pid });
}
if (this.rawData !== undefined && this.rawData !== null) {
return this.rawData;
}
throw new Error(
`Cannot serialize content for member ${this.pid} without object content`,
);
}
/**
* Set desired System Metadata.
* @param {SystemMetadata|object|null} sysMeta Desired System Metadata
* @param {object} [options] Update options
* @param {boolean} [options.markDirty] Whether this is a local edit
* @returns {DataPackageMember} This member
* @throws {Error} When an ambiguous remote write is unresolved
*/
setSystemMetadata(sysMeta, { markDirty = true } = {}) {
if (markDirty && this.remoteState === REMOTE_STATES.AMBIGUOUS) {
throw new Error(
"Resolve the ambiguous remote write before changing System Metadata",
);
}
this.sysMeta = sysMeta ? new SystemMetadata(sysMeta) : null;
this.sysMetaDirty = markDirty === true;
if (!markDirty) {
this.remoteSysMeta = this.sysMeta?.clone() || null;
this._remoteSysMetaDownloaded = false;
this._remoteSysMetaParseWarnings = [];
} else if (this.contentDirty) {
this.remoteState = REMOTE_STATES.PENDING;
}
return this;
}
/**
* Mark the member for removal from the package.
* @returns {DataPackageMember} This member
*/
markRemoved() {
this.removed = true;
return this;
}
/**
* Mark the member's remote write as in progress.
* @returns {DataPackageMember} This member
*/
markRemoteUploading() {
this.remoteState = REMOTE_STATES.UPLOADING;
this.lastUploadError = null;
return this;
}
/**
* Record a confirmed remote write and clear local dirty state.
* @param {object} [options] Confirmation details
* @param {string} [options.pid] Confirmed remote PID
* @param {object} [options.response] DataONE write response
* @param {SystemMetadata|object} [options.sysMeta] Confirmed system metadata
* @returns {DataPackageMember} This member
* @throws {Error} When the confirmed PID is missing or does not match
*/
markRemoteSuccess({ pid = null, response = null, sysMeta = null } = {}) {
const confirmedPid = Values.requireNonEmptyString(
response ? response?.data?.identifier : pid || this.pid,
response
? "Upload response did not include an identifier"
: "DataPackageMember.markRemoteSuccess requires a PID",
);
if (confirmedPid !== this.pid) {
throw new Error(
response
? "Upload response identifier does not match desired PID"
: "Confirmed remote PID does not match desired PID",
);
}
this.remotePid = confirmedPid;
const confirmedSysMeta = sysMeta || this.sysMeta;
this.sysMeta = confirmedSysMeta
? new SystemMetadata(confirmedSysMeta)
: null;
// A caller-supplied sysMeta is server-confirmed (ambiguous-write
// verification), so it is a trustworthy remote baseline. The sysMeta we
// built locally for a write is not: the server assigns serialVersion,
// dateUploaded, and dateSysMetadataModified on write. Drop the remote
// baseline in that case so the next edit re-fetches a fresh one before
// building another System Metadata update.
this.remoteSysMeta = sysMeta ? this.sysMeta.clone() : null;
this._remoteSysMetaDownloaded = Boolean(sysMeta);
this._remoteSysMetaParseWarnings = sysMeta?.parseWarnings
? [...sysMeta.parseWarnings]
: [];
// Upload bytes are scoped to the confirmed write. Future model-backed
// saves must serialize the current objectModel instead of replaying them.
this.uploadFile = null;
this.contentDirty = false;
this.sysMetaDirty = false;
this.accessPolicyDirty = false;
this.remoteState = REMOTE_STATES.UPLOADED;
this.lastUploadError = null;
this._replacementDisplay = null;
this._replacementSourcePid = null;
this._replacementSourceSysMeta = null;
return this;
}
/**
* Record a failed or ambiguous remote write.
* @param {Error} error Write error
* @param {object} [options] Failure options
* @param {boolean} [options.ambiguous] Whether the write may have committed
* @returns {DataPackageMember} This member
*/
markRemoteFailure(error, { ambiguous = false } = {}) {
this.remoteState = ambiguous
? REMOTE_STATES.AMBIGUOUS
: REMOTE_STATES.FAILED;
this.lastUploadError = error || null;
return this;
}
/**
* Align the aggregated PID with confirmed remote state.
* @returns {DataPackageMember} This member
* @throws {Error} When an active member is not confirmed remote
*/
promoteAggregatedState() {
if (!this.removed && this.pid !== this.remotePid) {
throw new Error("Cannot aggregate a PID that is not confirmed remote");
}
this.aggregatedPid = this.removed ? null : this.pid;
return this;
}
/**
* Return the remote operation required by the member's dirty state.
* @returns {string} Required operation
*/
getRequiredOperation() {
if (this.removed) return REQUIRED_OPERATIONS.REMOVE;
if (this.contentDirty) {
return this.remotePid
? REQUIRED_OPERATIONS.UPDATE
: REQUIRED_OPERATIONS.CREATE;
}
if (this.sysMetaDirty) {
return REQUIRED_OPERATIONS.UPDATE_SYSTEM_METADATA;
}
return REQUIRED_OPERATIONS.NONE;
}
/**
* Return the best available member file name.
* @returns {string|null} File name, or null
*/
getFileName() {
return (
this.fileName ||
this.filename ||
this.sysMeta?.fileName ||
this.viewServiceEntity?.fileName ||
null
);
}
/**
* Inject the loaded ObjectFormats collection so this member can classify
* its format type synchronously. Excluded from toJSON()/merge() so the
* collection is never serialized or copied between members.
* @param {ObjectFormats} objectFormats Loaded ObjectFormats collection
* @returns {DataPackageMember} This member
*/
setObjectFormats(objectFormats) {
this.objectFormats = objectFormats || FALLBACK_OBJECT_FORMATS;
return this;
}
/**
* Collect the format fields used for object format classification.
* @returns {{formatType: string, formatId: string, mediaType: string, filename: string}} Format fields
*/
getFormatProperties() {
const sysMetaMediaType = this.sysMeta?.mediaType;
const memberMediaType = this.mediaType;
const sysMetaMediaTypeName =
typeof sysMetaMediaType === "object"
? sysMetaMediaType?.name
: sysMetaMediaType;
const memberMediaTypeName =
typeof memberMediaType === "object"
? memberMediaType?.name
: memberMediaType;
return {
formatType: Values.normalizeText(this.formatType) || "",
formatId:
Values.normalizeText(this.sysMeta?.formatId) ||
Values.normalizeText(this.formatId) ||
"",
mediaType:
Values.normalizeText(sysMetaMediaTypeName) ||
Values.normalizeText(memberMediaTypeName) ||
"",
filename: Values.normalizeText(this.getFileName()) || "",
};
}
/**
* Return the member's DataONE format type.
* @returns {string|null} Format type, or null when unknown
*/
getFormatType() {
const props = this.getFormatProperties();
// A member known only by PID (e.g. an unresolved or private object) has
// no format signal at all: its type is unknown. Don't let ObjectFormats
// default it to octet-stream/DATA, which would leak such members into
// getData() and the file table.
if (
!props.formatType &&
!props.formatId &&
!props.mediaType &&
!props.filename
) {
return null;
}
return this.objectFormats.getFormatType(props);
}
/**
* Return the member's object format identifier.
* @returns {string|null} Format identifier, or null
*/
getFormatId() {
const props = this.getFormatProperties();
return this.objectFormats.getFormatId(props);
}
/**
* Check whether the member is science metadata.
* @returns {boolean} Whether the member is metadata
*/
isMetadata() {
const props = this.getFormatProperties();
return (
this.objectFormats.isMetadata(props) || this.objectFormats.isEML(props)
);
}
/**
* Check whether the member is a data object.
* @returns {boolean} Whether the member is data
*/
isData() {
const props = this.getFormatProperties();
if (
!props.formatType &&
!props.formatId &&
!props.mediaType &&
!props.filename
) {
return false;
}
return this.objectFormats.isData(props);
}
/**
* Check whether the member is a resource map.
* @returns {boolean} Whether the member is a resource map
*/
isResourceMap() {
const props = this.getFormatProperties();
return props.formatId
? this.objectFormats.isResourceMap(props)
: this.objectFormats.isResource(props);
}
/**
* Check whether the member uses an EML format.
* @returns {boolean} Whether the member is EML
*/
isEML() {
return this.objectFormats.isEML(this.getFormatProperties());
}
/**
* Check whether the member has a previewable image format.
* @returns {boolean} Whether the member can use an image preview
*/
isImage() {
return this.objectFormats.isImage(this.getFormatProperties());
}
/**
* Check whether the member is an index placeholder.
* @returns {boolean} Whether the member is a placeholder
*/
isPlaceholder() {
return this.isPlaceHolder_b === true;
}
/**
* Check whether the member's access policy is public.
* @param {object} [options] Options forwarded to `fetchSysMeta`
* @returns {Promise<boolean|null>} Public state, or null when unknown
*/
async isPublic(options = {}) {
if (!this.sysMeta && this.indexedIsPublic === true) return true;
if (!this.sysMeta) {
try {
await this.fetchSysMeta(options);
} catch (error) {
if (error?.status === 401 || error?.status === 403) return false;
return null;
}
}
const accessPolicy = this.sysMeta?.accessPolicy;
if (!accessPolicy) return null;
return accessPolicy.isPublic();
}
/**
* Serialize member state without runtime services, models, or upload bytes.
* @returns {object} Serializable member state
*/
toJSON() {
const json = {};
Object.entries(this).forEach(([key, value]) => {
if (
value === undefined ||
value === null ||
key.startsWith("_") ||
key === "uploadFile" ||
key === "objectModel" ||
key === "objectFormats" ||
key === "events" ||
key === "rawData" ||
key === "sysMetaReadDenied" ||
typeof value === "function" ||
value instanceof AuthorizationService ||
value instanceof ObjectService ||
value instanceof SysMetaService ||
(typeof Blob !== "undefined" && value instanceof Blob)
) {
return;
}
if (value instanceof Error) {
json[key] = {
name: value.name,
message: value.message,
code: value.code,
status: value.status,
};
return;
}
const serializable =
typeof value.toJSON === "function" ? value.toJSON() : value;
if (serializable === undefined) return;
if (serializable === null || typeof serializable !== "object") {
json[key] = serializable;
return;
}
try {
json[key] = JSON.parse(JSON.stringify(serializable));
} catch {
// Complex runtime-only values are intentionally omitted.
}
});
const fileName = this.getFileName();
if (fileName) json.fileName = fileName;
const { formatId } = this.getFormatProperties();
if (formatId) json.formatId = formatId;
const sysMeta = json.sysMeta || {};
if (!json.checksum && sysMeta.checksum) {
json.checksum = sysMeta.checksum;
}
if (!json.checksumAlgorithm && sysMeta.checksumAlgorithm) {
json.checksumAlgorithm = sysMeta.checksumAlgorithm;
}
return json;
}
/**
* Check the current user's permission for this member. Results are cached
* in memory on the member, keyed by user subject.
* @param {string} [action] Permission action. Defaults to "write"
* @param {object|boolean} [options] Options or refresh boolean
* @param {boolean} [options.refresh] Whether to bypass the cache and check again
* @param {AbortSignal} [options.signal] Signal used to cancel permission checks
* @param {AuthorizationService} [authorizationService] Service used to
* resolve the current user and check permission
* @returns {Promise<boolean>} Permission result
*/
async checkPermission(
action = "write",
options = {},
authorizationService = null,
) {
const opts =
typeof options === "boolean" ? { refresh: options } : options || {};
const authOpts = { ...(opts.auth || {}) };
const requestOptions = { ...authOpts };
if (opts.signal) requestOptions.signal = opts.signal;
const service =
authorizationService || new AuthorizationService(authOpts);
const user = await service.getUserKey();
this.permissions = this.permissions || {};
if (!this.permissions[user]) {
this.permissions[user] = {};
}
if (
opts.refresh !== true &&
Values.hasOwn(this.permissions[user], action) &&
typeof this.permissions[user][action] === "boolean"
) {
return this.permissions[user][action];
}
let can;
try {
can = await service.check(this.pid, action, requestOptions);
} catch (error) {
if (ErrorUtilities.isAbortError(error)) return false;
// eslint-disable-next-line no-console
console.error(
`Error checking ${action} permission for PID ${this.pid}:`,
error,
);
return false;
}
this.permissions[user][action] = can;
return can;
}
/**
* Check write permission for the current user.
* @param {object|boolean} [options] Options or refresh boolean
* @param {AuthorizationService} [authorizationService] Service used to
* resolve the current user and check permission
* @returns {Promise<boolean>} Write permission result
*/
checkWritePermission(options = {}, authorizationService = null) {
return this.checkPermission("write", options, authorizationService);
}
/**
* Convert this member's PID to the legacy XML-safe identifier form.
* @returns {string|null} XML-safe identifier, or null for an empty PID
*/
getXMLSafeID() {
return XMLUtilities.getXMLSafeID(this.pid);
}
}
DataPackageMember.RemoteState = REMOTE_STATES;
DataPackageMember.RequiredOperation = REQUIRED_OPERATIONS;
DataPackageMember.ResourceMapGraphFields = RESOURCE_MAP_GRAPH_FIELDS;
return DataPackageMember;
});