src/lib/server/AtviseFile.js
import { readFile } from 'fs';
import { dirname } from 'path';
import { NodeClass } from 'node-opcua/lib/datamodel/nodeclass';
import { DataType, VariantArrayType, Variant } from 'node-opcua/lib/datamodel/variant';
import { resolveNodeId } from 'node-opcua/lib/datamodel/nodeid';
import { LocalizedText } from 'node-opcua/lib/datamodel/localized_text';
import { StatusCodes } from 'node-opcua/lib/datamodel/opcua_status_code';
import { QualifiedName } from 'node-opcua/lib/datamodel/qualified_name';
import { DataValue } from 'node-opcua/lib/datamodel/datavalue';
import { ReferenceTypeIds } from 'node-opcua/lib/opcua_node_ids';
import { ExpandedNodeId } from 'node-opcua/lib/datamodel/expanded_nodeid';
import { DiagnosticInfo } from 'node-opcua/lib/datamodel/diagnostic_info';
import File from 'vinyl';
import NodeId from '../model/opcua/NodeId';
import { reverse, pick } from '../helpers/Object';
import { sortReferences } from '../helpers/mapping';
import AtviseTypes, { AtviseResourceType } from './Types';
/**
* A map of AtviseTypes against their definition id's value.
* @type {Map<String, AtivseType>}
*/
const AtviseTypesByValue = AtviseTypes.reduce(
(result, type) =>
Object.assign(result, {
[type.typeDefinition.value]: type,
}),
{}
);
/**
* A map of AtviseTypes against their identifiers.
* @type {Map<String, AtivseType>}
*/
const AtviseTypesByIdentifier = AtviseTypes.reduce(
(result, type) =>
Object.assign(result, {
[type.identifier]: type,
}),
{}
);
/**
* A map providing shorter extensions for data types
* @type {Map<node-opcua~DataType, String>}
*/
export const ExtensionForDataType = {
[DataType.Boolean]: 'bool',
[DataType.XmlElement]: 'xml',
};
/**
* A map providing data types for shorter extensions (Reverse of {@link DataTypeForExtension}).
* * @type {Map<String, node-opcua~DataType>}
*/
export const DataTypeForExtension = reverse(ExtensionForDataType);
// Cache DataType
/**
* OPC-UA data type names.
* @type {String[]}
*/
const types = Object.keys(DataType);
/**
* OPC-UA data type extensions.
* @type {String[]}
*/
const typeExtensions = types.map((t) => t.toLowerCase());
// Cache TypeDefinitions
/**
* Variable data type definition node id.
* @type {NodeId}
*/
const VariableTypeDefinition = new NodeId(NodeId.NodeIdType.NUMERIC, 62, 0);
/**
* Property data type definition node id.
* @type {NodeId}
*/
const PropertyTypeDefinition = new NodeId(NodeId.NodeIdType.NUMERIC, 68, 0);
// Cache Regular expressions
/**
* A regular expression picking file extensions from file names.
* @type {RegExp}
*/
const ExtensionRegExp = /\.([^/\\]*)$/;
/**
* Function that returns the passed argument as is.
* @param {*} b The input argument.
* @return {*} The value passed.
*/
const asIs = (b) => b;
/**
* Maps a single property of an object using the the mapper defined in *map* for the given
* *dataType*.
* @param {Map<node-opcua~DataType, function} map The mappings to use.
* @param {Object} obj The object to process.
* @param {string} key Name of the property to map.
* @param {node-opcua~DataType} dataType The data type to map the property to.
*/
const mapPropertyAs = (map, obj, key, dataType) => {
if (obj[key]) {
return Object.assign(obj, {
[key]: map[dataType](obj[key]),
});
}
return obj;
};
/**
* Mapping functions that return serializable values for a node of the given
* {@link node-opcua~DataType}.
* @type {Map<node-opcua~DataType, function>}
*/
const toRawValue = {
[DataType.Null]: () => null,
[DataType.StatusCode]: ({ name }) => name,
[DataType.QualifiedName]: ({ namespaceIndex, name }) => ({ namespaceIndex, name }),
[DataType.LocalizedText]: ({ text, locale }) => ({ text, locale }),
[DataType.DataValue]: (value) => {
const options = pick(value, [
'value',
'statusCode',
'sourceTimestamp',
'sourcePicoseconds',
'serverTimestamp',
'serverPicoseconds',
]);
mapPropertyAs(toRawValue, options, 'value', DataType.Variant);
mapPropertyAs(toRawValue, options, 'statusCode', DataType.StatusCode);
// NOTE: server- and sourceTimstamps get mapped as dates
return options;
},
[DataType.Variant]: ({ dataType, arrayType, value, dimensions }) => ({
dataType,
arrayType,
// eslint-disable-next-line no-use-before-define
value: getRawValue(value, dataType, arrayType),
dimensions,
}),
[DataType.DiagnosticInfo]: (info) => {
const options = pick(info, [
'namespaceUri',
'symbolicId',
'locale',
'localizedText',
'additionalInfo',
'innerStatusCode',
'innerDiagnosticInfo',
]);
mapPropertyAs(toRawValue, options, 'innerStatusCode', DataType.StatusCode);
mapPropertyAs(toRawValue, options, 'innerDiagnosticInfo', DataType.DiagnosticInfo);
return options;
},
};
/**
* Decodes a buffer to a string.
* @param {Buffer} b The buffer to decode from.
* @return {string} The buffer's string representation.
*/
const decodeAsString = (b) => b.toString().trim();
/**
* Decodes a buffer to an integer value.
* @param {Buffer} b The buffer to decode from.
* @return {number} The decoded integer.
*/
const decodeAsInt = (b) => parseInt(decodeAsString(b), 10);
/**
* Decodes a buffer to a float value.
* @param {Buffer} b The buffer to decode from.
* @return {number} The decoded float.
*/
const decodeAsFloat = (b) => parseFloat(decodeAsString(b));
/**
* Decodes a buffer using JSON.
* @param {Buffer} b The buffer to decode from.
* @return {*} The decoded value, most likely an Object.
*/
const decodeAsJson = (b) => JSON.parse(b.toString());
/**
* Mapping functions that return raw values for a stored value of the given type.
* @type {Map<node-opcua~DataType, function>}
*/
const decodeRawValue = {
[DataType.Null]: () => null,
[DataType.Boolean]: (b) => decodeAsString(b) === 'true',
[DataType.SByte]: decodeAsInt,
[DataType.Byte]: decodeAsInt,
[DataType.Int16]: decodeAsInt,
[DataType.UInt16]: decodeAsInt,
[DataType.Int32]: decodeAsInt,
[DataType.UInt32]: decodeAsInt,
[DataType.Int64]: decodeAsJson,
[DataType.UInt64]: decodeAsJson,
[DataType.Float]: decodeAsFloat,
[DataType.Double]: decodeAsFloat,
[DataType.String]: decodeAsString,
[DataType.DateTime]: decodeAsString,
[DataType.Guid]: decodeAsString,
// ByteString maps to Buffer
[DataType.XmlElement]: decodeAsString,
[DataType.NodeId]: decodeAsString,
[DataType.ExpandedNodeId]: decodeAsString,
[DataType.StatusCode]: decodeAsString,
[DataType.QualifiedName]: decodeAsJson,
[DataType.LocalizedText]: decodeAsJson,
// FIXME: Add ExtensionObject
[DataType.DataValue]: decodeAsJson,
[DataType.Variant]: decodeAsJson,
[DataType.DiagnosticInfo]: decodeAsJson,
};
/**
* Mapping functions that return OPC-UA node values for raw values.
* @type {Map<node-opcua~DataType, function>}
*/
const toNodeValue = {
[DataType.DateTime]: (s) => new Date(s),
[DataType.ByteString]: (b) => {
if (b instanceof Buffer) {
return b;
}
return Buffer.from(b.data, 'binary');
},
[DataType.NodeId]: (s) => resolveNodeId(s),
// Jep, node-opcua does not provide a resolve function for expanded nodeids
[DataType.ExpandedNodeId]: (s) => {
const nodeId = resolveNodeId(s);
const [value, ...defs] = nodeId.value.split(';');
const { identifierType, namespace, namespaceUri, serverIndex } = defs.reduce((opts, def) => {
const match = def.match(/^([^:]+):(.*)/);
if (!match) {
return opts;
}
let [key, val] = match.slice(1); // eslint-disable-line prefer-const
if (key === 'serverIndex') {
val = parseInt(val, 10);
}
return Object.assign(opts, { [key]: val });
}, Object.assign({}, nodeId));
return new ExpandedNodeId(identifierType, value, namespace, namespaceUri, serverIndex);
},
[DataType.StatusCode]: (name) => StatusCodes[name],
[DataType.QualifiedName]: (options) => new QualifiedName(options),
[DataType.LocalizedText]: (options) => new LocalizedText(options),
[DataType.DataValue]: (options) => {
const opts = options;
mapPropertyAs(toNodeValue, opts, 'value', DataType.Variant);
mapPropertyAs(toNodeValue, opts, 'statusCode', DataType.StatusCode);
mapPropertyAs(toNodeValue, opts, 'sourceTimestamp', DataType.DateTime);
mapPropertyAs(toNodeValue, opts, 'serverTimestamp', DataType.DateTime);
return new DataValue(opts);
},
[DataType.Variant]: ({ dataType, arrayType, value, dimensions }) =>
new Variant({
dataType,
arrayType: VariantArrayType[arrayType],
value,
dimensions,
}),
[DataType.DiagnosticInfo]: (options) => {
const opts = options;
mapPropertyAs(toNodeValue, opts, 'innerStatusCode', DataType.StatusCode);
mapPropertyAs(toNodeValue, opts, 'innerDiagnosticInfo', DataType.DiagnosticInfo);
return new DiagnosticInfo(opts);
},
};
/**
* Returns a node's raw value based on it's OPC-UA value and type.
* @param {*} value A node's OPC-UA value.
* @param {node-opcua~DataType} dataType The node's data type.
* @param {node-opcua~VariantArrayType} arrayType The node's array type.
* @return {*} The raw value of the given node.
*/
const getRawValue = (value, dataType, arrayType) => {
if (arrayType.value !== VariantArrayType.Scalar.value) {
const array = Array.isArray(value) ? value : Array.from(value);
return array.map((val) => getRawValue(val, dataType, VariantArrayType[arrayType.value - 1]));
}
return (toRawValue[dataType] || asIs)(value);
};
/**
* Returns a node's OPC-UA value based on it's raw value and type.
* @param {*} rawValue A node's raw value.
* @param {node-opcua~DataType} dataType A node's data type.
* @param {node-opcua~VariantArrayType} arrayType A node's array type.
*/
const getNodeValue = (rawValue, dataType, arrayType) => {
if (arrayType.value !== VariantArrayType.Scalar.value) {
if (!Array.isArray(rawValue)) {
throw new Error('Value is not an array');
}
return rawValue.map((raw) =>
getNodeValue(raw, dataType, VariantArrayType[arrayType.value - 1])
);
}
return (toNodeValue[dataType] || asIs)(rawValue);
};
/**
* Returns the extension for a specific {@link node-opcua~DataType}.
* Algorithm:
* - if the type has a shortened extension defined in {@link ExtensionForDataType}, return it.
* - else return the DataType's name, in lowercase letters.
* @param {node-opcua~DataType} dataType The datatype to get the extension for.
* @return {string} The resulting extension.
*/
function extensionForDataType(dataType) {
return ExtensionForDataType[dataType] || dataType.toString().toLowerCase();
}
/**
* A regular expression that maches all reference definition files.
* @type {RegExp}
*/
const ConfigFileRegexp = /^\.((Object|Variable)(Type)?|Method|View|(Reference|Data)Type)\.json$/;
/**
* An extension to {@link vinyl~File} providing some additional, atvise-related properties.
* @property {node-opcua~DataType} AtviseFile#dataType The {@link node-opcua~DataType} the node is
* stored against on atvise server.
* @property {NodeId} typeDefinition The file's type definition on atvise server.
* FIXME: Additional properties not showing in API docs.
*/
export default class AtviseFile extends File {
/**
* Returns a storage path for a {@link ReadStream.ReadResult}.
* @param {ReadStream.ReadResult} readResult The read result to get a path for.
*/
static pathForReadResult(readResult) {
let path = readResult.nodeId.filePath;
if (readResult.nodeClass.value !== NodeClass.Variable.value) {
return `${path}/.${readResult.nodeClass.key}.json`;
}
const dataType = readResult.value.$dataType;
const arrayType = readResult.value.$arrayType;
const typeDefinition = readResult.references.HasTypeDefinition[0];
if (typeDefinition.value === VariableTypeDefinition.value) {
// Variable nodes are stored with their lowercase datatype as an extension
path += `.${extensionForDataType(dataType)}`;
} else if (typeDefinition.value === PropertyTypeDefinition.value) {
// Property nodes are stored with ".prop" and their lowercase datatype as an extension
path += `.prop.${extensionForDataType(dataType)}`;
} else {
// Handle custom types
let identifier = 'var';
let fileExtension = false;
let keepExtension = false;
const atType = AtviseTypesByValue[typeDefinition.value];
if (atType) {
identifier = atType.identifier;
fileExtension = atType.fileExtension;
keepExtension = atType.keepExtension;
}
if (!keepExtension) {
path += `.${identifier}.${fileExtension || extensionForDataType(dataType)}`;
}
}
// Add "array" or "matrix" extensions for corresponding array types
if (arrayType.value !== VariantArrayType.Scalar.value) {
path += `.${arrayType === VariantArrayType.Array ? 'array' : 'matrix'}`;
}
return path;
}
/**
* Encodes a node's value to file contents.
* @param {*} value The value to encode.
* @param {node-opcua~DataType} dataType The {@link node-opcua~DataType} to encode the value for.
* @param {node-opcua~VariantArrayType} arrayType The files array type.
* @return {?Buffer} The encoded file contents or null.
*/
static encodeValue(value, dataType, arrayType) {
if (value.value === null) {
return Buffer.from('');
}
const rawValue = getRawValue(value.value, dataType, arrayType);
if (rawValue instanceof Buffer) {
return rawValue;
}
const stringify = (a) => (a.toJSON ? a.toJSON() : JSON.stringify(a, null, ' '));
const stringified =
typeof rawValue === 'object' ? stringify(rawValue) : rawValue.toString().trim();
return Buffer.from(stringified);
}
/**
* Decodes a file's contents to a node's value.
* @param {Buffer} buffer The file contents to decode.
* @param {node-opcua~DataType} dataType The {@link node-opcua~DataType} to decode the contents.
* @param {node-opcua~VariantArrayType} arrayType The files array type.
* @return {?*} The decoded node value or null.
*/
static decodeValue(buffer, dataType, arrayType) {
if (buffer === null || buffer.length === 0) {
return null;
}
if (dataType === DataType.ByteString && arrayType === VariantArrayType.Scalar) {
return buffer;
}
const rawValue =
arrayType === VariantArrayType.Scalar
? (decodeRawValue[dataType] || asIs)(buffer)
: JSON.parse(buffer.toString());
return getNodeValue(rawValue, dataType, arrayType);
}
/**
* As file mtimes do not support millisecond resolution these must be removed before storing
* files.
* @param {Date} date The original mtime.
* @return {Date} The normalized mtime.
*/
static normalizeMtime(date) {
date.setMilliseconds(0);
return date;
}
/**
* Creates a new {@link AtviseFile} for the given {@link ReadStream.ReadResult}.
* @param {ReadStream.ReadResult} readResult The read result to create the file for.
* @return {AtviseFile} The resulting file.
*/
static fromReadResult(readResult) {
const { nodeClass, references, value, mtime } = readResult;
if (nodeClass.value === NodeClass.Variable.value && !value) {
throw new Error('no value');
}
return new AtviseFile({
path: AtviseFile.pathForReadResult(readResult),
contents: value
? AtviseFile.encodeValue(value, value.$dataType, value.$arrayType) // Variables
: Buffer.from(
JSON.stringify(
{
references: sortReferences(references),
},
null,
' '
)
), // Objects, types, ...
_nodeClass: nodeClass,
_dataType: value && value.$dataType,
_arrayType: value && value.$arrayType,
_references: references,
stat: { mtime: mtime ? this.normalizeMtime(mtime) : undefined },
});
}
/**
* Recalculates {@link AtviseFile#dataType}, {@link AtviseFile#arrayType} and
* {@link AtviseFile#typeDefinition}. **Never call this method directly.**.
*/
_getMetadata() {
if (this.stem[0] === '.') {
// Got non-variable node
/**
* The node's class.
* @type {node-opcua~NodeClass}
*/
this._nodeClass = NodeClass[this.stem.split('.')[1]];
const { references = {} } = JSON.parse(this.contents.toString());
/**
* References the node holds: In most cases this will be a single entry for
* `'HasTypeDefinition'`.
* @type {Map<String, NodeId[]>}
*/
this._references = Object.entries(references).reduce(
(result, [type, refs]) =>
Object.assign(result, {
[type]: Array.isArray(refs) ? refs.map((v) => new NodeId(v)) : new NodeId(refs),
}),
{}
);
return;
}
this._nodeClass = NodeClass.Variable;
// Set default metadata
/**
* The node's stored {@link node-opcua~VariantArrayType}.
* @type {?node-opcua~VariantArrayType}
*/
this._arrayType = VariantArrayType.Scalar;
this._references = {};
/**
* A node's browse- and display name.
* @type {?string}
*/
this._name = this.stem.split('.')[0];
let extensions = [];
const m = this.relative.match(ExtensionRegExp);
if (m) {
extensions = m[1].split('.');
}
// For split files, add the directory name extension
const dirnameExts = this.dirname.split('.');
if (dirnameExts.length > 1) {
extensions.unshift(dirnameExts[dirnameExts.length - 1]);
}
function ifLastExtensionMatches(matches, fn) {
if (matches(extensions[extensions.length - 1])) {
fn(extensions.pop());
}
}
const complete = () =>
this._dataType !== undefined && this._references.HasTypeDefinition !== undefined;
// Handle array types
ifLastExtensionMatches(
(ext) => ext === 'array',
() => {
this._arrayType = VariantArrayType.Array;
}
);
ifLastExtensionMatches(
(ext) => ext === 'matrix',
() => {
this._arrayType = VariantArrayType.Matrix;
}
);
ifLastExtensionMatches(
(ext) => typeExtensions.includes(ext),
(ext) => {
/**
* The node's stored {@link node-opcua~DataType}.
* @type {?node-opcua~DataType}
*/
this._dataType = DataType[types[typeExtensions.indexOf(ext)]];
}
);
// Handle wrapped data types (e.g. "bool" for DataType.Boolean)
ifLastExtensionMatches(
(ext) => DataTypeForExtension[ext],
(ext) => {
this._dataType = DataType[DataTypeForExtension[ext]];
}
);
if (extensions.length === 0) {
// Got variable
/**
* The node's stored type definition.
* @type {?node-opcua~NodeId}
*/
this._references.HasTypeDefinition = [new NodeId(NodeId.NodeIdType.NUMERIC, 62, 0)];
}
ifLastExtensionMatches(
(ext) => ext === 'prop',
() => {
this._references.HasTypeDefinition = [new NodeId(NodeId.NodeIdType.NUMERIC, 68, 0)];
this._references.toParent = ReferenceTypeIds.HasProperty;
}
);
ifLastExtensionMatches(
(ext) => ext === 'var',
() => {
this._references.HasTypeDefinition = [new NodeId('Custom.VarResourceType')];
}
);
if (!complete()) {
// Handle atvise types
let foundAtType = false;
Object.entries(AtviseTypesByIdentifier).forEach(([identifier, type]) => {
if (!foundAtType && extensions.includes(identifier)) {
foundAtType = true;
if (!(type instanceof AtviseResourceType)) {
extensions = extensions.filter((e) => e !== identifier);
}
this._references.HasTypeDefinition = [type.typeDefinition];
this._dataType = type.dataType;
}
});
}
if (!complete()) {
this._references.HasTypeDefinition = [
new NodeId('VariableTypes.ATVISE.Resource.OctetStream'),
];
this._dataType = DataType.ByteString;
}
this._name = [this._name, ...extensions.filter((e) => !dirnameExts.includes(e))].join('.');
}
/**
* Computes a file's metadata if needed.
* @return {AtviseFile} The file.
*/
getMetadata() {
if (!this._nodeClass) {
this._getMetadata();
}
return this;
}
/**
* The node's class.
* @type {node-opcua~NodeClass}
*/
get nodeClass() {
if (!this._nodeClass) {
this._getMetadata();
}
return this._nodeClass;
}
/**
* The file's {@link node-opcua~DataType}.
* @type {node-opcua~DataType}
*/
get dataType() {
if (!this._dataType) {
this._getMetadata();
}
return this._dataType;
}
/**
* The file's {@link node-opcua~VariantArrayType}.
* @type {node-opcua~VariantArrayType}
*/
get arrayType() {
if (!this._arrayType) {
this._getMetadata();
}
return this._arrayType;
}
/**
* The files's references.
* @type {Map<string, NodeId|NodeId[]>}
*/
get references() {
if (!this._references) {
this._getMetadata();
}
return this._references;
}
/**
* The file's type definition.
* @type {node-opcua~NodeId}
*/
get typeDefinition() {
const refs = this.references;
if (refs && refs.HasTypeDefinition) {
return refs.HasTypeDefinition[0];
}
return new NodeId(NodeId.NodeIdType.NUMERIC, 0, 0);
}
// eslint-disable-next-line jsdoc/require-description-complete-sentence
/**
* `true` for reference config files (for example `.index.htm.json`).
* @type {boolean}
*/
get isReferenceConfig() {
return this.stem[0] === '.' && !this.stem.match(ConfigFileRegexp);
}
/**
* `true` for files containing atvise displays.
* @type {boolean}
*/
get isDisplay() {
return this.typeDefinition.value === 'VariableTypes.ATVISE.Display';
}
/**
* `true` for files containing atvise scripts.
* @type {boolean}
*/
get isScript() {
return this.typeDefinition.value === 'VariableTypes.ATVISE.ScriptCode';
}
/**
* `true` for files containing atvise quick dynamics.
* @type {boolean}
*/
get isQuickDynamic() {
return this.typeDefinition.value === 'VariableTypes.ATVISE.QuickDynamic';
}
/**
* Sets the node value for the file.
* @param {?*} newValue The value to set.
*/
set value(newValue) {
/**
* The file's contents.
* @type {?Buffer}
*/
this.contents = AtviseFile.encodeValue(newValue, this.dataType, this.arrayType);
}
/**
* Returns the decoded node value for the file.
* @type {?*} The file's decoded value.
*/
get value() {
return AtviseFile.decodeValue(this.contents, this.dataType, this.arrayType);
}
/**
* Returns the decoded node value for create node serverscript.
* @type {?*} The file's decoded value.
*/
get createNodeValue() {
const value = this.value;
if (this.dataType === DataType.DateTime) {
return value.valueOf();
}
if (value instanceof Buffer) {
return value.toString();
}
return value;
}
/**
* Returns the node id associated with the file.
* @type {NodeId} The file's node id.
*/
get nodeId() {
if (this.nodeClass.value !== NodeClass.Variable.value) {
return NodeId.fromFilePath(dirname(this.relative));
}
const atType = AtviseTypesByValue[this.typeDefinition.value];
let idPath = this.relative;
if (!atType || !atType.keepExtension) {
const exts = idPath.match(ExtensionRegExp)[1];
idPath = idPath.split(`.${exts}`)[0];
}
return NodeId.fromFilePath(idPath);
}
/**
* A file's browse and display name.
* @type {string}
*/
get name() {
if (!this._name) {
this._getMetadata();
}
return this._name;
}
/**
* A file's parent's node id.
* @type {NodeId}
*/
get parentNodeId() {
const name = this.name;
const id = this.nodeId;
if (name) {
const parts = this.nodeId.value.split(name);
if (parts.length > 1) {
return new NodeId(id.identifierType, parts[0].slice(0, -1), id.namespaceIndex);
}
}
return this.nodeId.parent;
}
/**
* Returns a new file with all attributes of the current file.
* @param {Object} options See the {@link vinyl~File} docs for all options available.
* @return {AtviseFile} The cloned file.
* @see https://github.com/gulpjs/vinyl#filecloneoptions
*/
clone(options) {
const clonedFile = super.clone(options);
clonedFile._nodeClass = this._nodeClass;
clonedFile._dataType = this._dataType;
clonedFile._arrayType = this._arrayType;
clonedFile._references = this._references;
return clonedFile;
}
/**
* Creates a new AtviseFile and reads it's contents.
* @param {Object} options See {@link vinyl~File} for available options.
* @return {Promise} Resolved with the new file of rejected with the error that occured while
* trying to read it's path.
*/
static read(options = {}) {
return new Promise((resolve, reject) => {
if (!options.path) {
reject(new Error('options.path is required'));
} else {
readFile(options.path, (err, contents) => {
if (err) {
reject(err);
} else {
resolve(new AtviseFile(Object.assign(options, { contents })));
}
});
}
});
}
}