src/lib/gulp/dest.js
import { Writable } from 'stream';
import { join } from 'path';
import { EOL } from 'os';
import { NodeClass } from 'node-opcua/lib/datamodel/nodeclass';
import { outputFile, readJson } from 'fs-extra';
import hasha from 'hasha';
import Logger from 'gulplog';
import ProjectConfig from '../../config/ProjectConfig';
import { encodeVariant } from '../coding';
/**
* Relative path to the rename file.
* @type {string}
*/
export const renameConfigPath = './atscm/rename.json';
/**
* The default name inserted into the rename file.
* @type {string}
*/
const renameDefaultName = 'insert node name';
/**
* Options to pass to *hasha*.
* @type {Object}
*/
const hashaOptions = { algorithm: 'md5' };
/**
* If checksums should be used to decide if writes are needed.
* @type {boolean}
*/
const useChecksums = ProjectConfig.vcs === 'svn';
const escapePathComponent = (a) => a.replace(/\//g, '%2F');
/**
* A stream that writes {@link Node}s to the file system.
*/
export class WriteStream extends Writable {
/**
* Creates a new WriteStream.
* @param {Object} options The options to use.
* @param {string} options.path The path to write to **(required)**.
* @param {string} options.base The base path to write to (defaults to *path*).
* @param {boolean} [options.cleanRenameConfig=false] If unused entries should be removed when
* rename config is written.
*/
constructor(options) {
if (!options.path) {
throw new Error('Missing `path` option');
}
super(Object.assign({}, options, { objectMode: true, highWaterMark: 10000 }));
/**
* If the stream is destroyed.
* @type {boolean}
*/
this._isDestroyed = false;
/**
* The number of processed nodes.
* @type {number}
*/
this._processed = 0;
/**
* The number of written nodes.
* @type {number}
*/
this._written = 0;
/**
* The base to output to.
* @type {string}
*/
this._base = options.base || options.path;
/**
* The object stored in the *rename file* (usually at './atscm/rename.json')
*/
this._renameConfig = {};
this._renamesUsed = {};
this._cleanRenameConfig = options.cleanRenameConfig || false;
/**
* A promise that resolves once the *rename file* is loaded.
* @type Promise<Object>
*/
this._loadRenameConfig = readJson(renameConfigPath)
.then((config) => (this._renameConfig = config))
.catch(() => Logger.debug('No rename config file loaded'));
/**
* A map of ids used for renaming.
*/
this._idMap = new Map();
/**
* If writes should actually be performed. Set to `false` once id conflicts were discovered.
*/
this._performWrites = true;
/**
* The IDs that are affected by node id conflicts, lowercased.
* @type {Set<string>}
*/
this._conflictingIds = new Set();
/**
* The number of id conflicts discovered.
* @type {number}
*/
this._discoveredIdConflicts = 0;
if (useChecksums) {
Logger.info('Optimizing for SVN diffs');
}
}
/**
* If the stream is destroyed.
* @type {boolean}
*/
get isDestroyed() {
return this._isDestroyed;
}
/**
* Transverses the node tree to see if any parent node has an id conflict.
* @param {ServerNode} node The processed node.
* @return {boolean} `true` if a parent node has an id conflict.
*/
_parentHasIdConflict(node) {
let current = node.parent;
while (current) {
if (this._conflictingIds.has(current.nodeId.toLowerCase())) {
return true;
}
current = current.parent;
}
return false;
}
async _outputFile(path, content) {
if (useChecksums) {
const oldSum = await hasha.fromFile(path, hashaOptions).catch(() => null);
if (oldSum) {
if (oldSum === hasha(content, hashaOptions)) {
Logger.debug(`Content did not change at ${path}`);
return Promise.resolve();
}
Logger.debug(`Content changed at ${path}`);
} else {
Logger.debug(`No checksums for ${path}`);
}
}
return outputFile(path, content);
}
/**
* Writes a single node to disk.
* @param {ServerNode} node The processed node.
* @return {Promise<boolean>} Resolves once the node has been written, `true` indicates the node
* has actually been written.
*/
async _writeNode(node) {
// TODO: Throw if node.name ends with '.inner'
const dirPath = node.filePath.map(escapePathComponent);
const writeOps = [];
// Rename nodes specified in the rename config
const rename = this._renameConfig[node.id.value];
if (rename && rename !== renameDefaultName) {
this._renamesUsed[node.id.value] = true;
node.renameTo(rename);
Logger.debug(`'${node.nodeId}' was renamed to '${rename}'`);
Object.assign(node, { _renamed: true });
}
// Resolve invalid ids
if (!node._renamed && node.nodeId !== node.id.value) {
Logger.debug(
`Resolved ID conflict: '${node.id.value}' should be renamed to '${node.nodeId}'`
);
}
Object.assign(node, { specialId: node.id.value });
if (node.name.match(/:/)) {
const before = node.name;
node.renameTo(node.name.replace(/:/g, '_'));
Logger.debug(`Resolved ID conflict: '${before}' was renamed to safe name '${node.name}'`);
}
// Detect "duplicate" ids (as file names are case insensitive)
const pathKey = dirPath.concat(node.fileName).join('/').toLowerCase();
if (this._idMap.has(pathKey)) {
if (this._parentHasIdConflict(node)) {
Logger.debug(`ID conflict: Skipping '${node.nodeId}'`);
} else {
Logger.error(`ID conflict: '${node.nodeId}' conflicts with '${this._idMap.get(pathKey)}'`);
this._discoveredIdConflicts++;
const existingRename = this._renameConfig[node.nodeId];
if (existingRename) {
if (existingRename === renameDefaultName) {
// eslint-disable-next-line max-len
Logger.error(
` - '${node.nodeId}' is present inside the rename file at './atscm/rename.json', but no name has been inserted yet.`
);
} else {
// eslint-disable-next-line max-len
Logger.error(
` - The name for '${node.nodeId}' inside './atscm/rename.json' is not unique.`
);
}
Logger.info(" - Edit the node's name and run 'atscm pull' again");
} else {
this._renameConfig[node.nodeId] = renameDefaultName;
Logger.info(` - '${node.nodeId}' was added to the rename file at './atscm/rename.json'`);
Logger.info("Edit it's name and run 'atscm pull' again.");
}
}
this._conflictingIds.add(node.nodeId.toLowerCase());
this._performWrites = false;
} else {
this._idMap.set(pathKey, node.nodeId);
}
// Write definition file (if needed)
if (node.hasUnresolvedMetadata) {
const name =
node.nodeClass === NodeClass.Variable
? `./.${escapePathComponent(node.fileName)}.json`
: `./${escapePathComponent(node.fileName)}/.${node.nodeClass.key}.json`;
if (this._performWrites) {
writeOps.push(
this._outputFile(
join(this._base, dirPath.join('/'), name),
JSON.stringify(node.metadata, null, ' ')
)
);
}
}
// Write value
if (node.nodeClass === NodeClass.Variable) {
if (node.value) {
if (!node.value.noWrite) {
if (this._performWrites) {
writeOps.push(
this._outputFile(
join(this._base, dirPath.join('/'), escapePathComponent(node.fileName)),
encodeVariant(node.value)
)
);
}
// Store child nodes as file.inner/...
node.renameTo(`${node.name}.inner`);
}
} else {
throw new Error('Missing value');
}
}
return Promise.all(writeOps)
.then(() => {
this._processed++;
this._written += writeOps.length;
})
.then(() => writeOps.length > 0);
}
/**
* Writes a single node to the file system.
* @param {Node} node The node to write.
* @param {string} enc The encoding used.
* @param {function(err: ?Error): void} callback Called once finished.
*/
_write(node, enc, callback) {
this._loadRenameConfig
.then(() => this._writeNode(node))
.then(() => callback())
.catch((err) => callback(err));
}
writeAsync(node) {
return new Promise((resolve, reject) => {
this._write(node, null, (err) => (err ? reject(err) : resolve()));
});
}
/**
* Writes multiple nodes in parallel.
* @param {Node[]} nodes The nodes to write.
* @param {function(error: ?Error): void} callback Called once all nodes have been written.
*/
_writev(nodes, callback) {
if (this.isDestroyed) {
return;
}
this._loadRenameConfig
.then(() => Promise.all(nodes.map(({ chunk }) => this._writeNode(chunk))))
.then(() => callback())
.catch((err) => callback(err));
}
/**
* Destroys the stream.
* @param {?Error} err The error that caused the destroy.
* @param {function(err: ?Error): void} callback Called once finished.
*/
_destroy(err, callback) {
this._isDestroyed = true;
super._destroy(err, callback);
}
/**
* Writes the updated rename config to disk.
*/
writeRenamefile() {
if (this._discoveredIdConflicts) {
Logger.error(
`Discovered ${this._discoveredIdConflicts} node id conflicts, results are incomplete.
- Resolve all conflicts inside '${renameConfigPath}' and run 'atscm pull' again`
);
// FIXME: Insert link to node id conflict manual here once 1.0.0 is released.
}
let renameConfig = this._renameConfig;
if (!this._discoveredIdConflicts && this._cleanRenameConfig) {
renameConfig = Object.keys(this._renamesUsed)
.sort()
.reduce(
(result, key) =>
Object.assign(result, {
[key]: this._renameConfig[key],
}),
{}
);
const renamesRemoved =
Object.keys(this._renameConfig).length - Object.keys(renameConfig).length;
if (renamesRemoved > 0) {
Logger.info(`Removed ${renamesRemoved} unused renames from rename configuration.`);
}
}
return outputFile(renameConfigPath, `${JSON.stringify(renameConfig, null, ' ')}${EOL}`);
}
}
/**
* Creates a new {@link WriteStream} to write to *path*.
* @param {string} path The path to write to.
* @param {Object} [options] The options to use. Passed to {@link WriteStream#constructor}.
*/
export default function dest(path, { cleanRenameConfig = false } = {}) {
return new WriteStream({ path, cleanRenameConfig });
}