/** * Functions generally useful when dealing with Grist documents. */ var fs = require('fs'); var fsPath = require('path'); var Promise = require('bluebird'); Promise.promisifyAll(fs); var nonIdentRegex = /[^\w_]+/g; /** * Given a string, converts it to a Grist identifier. Identifiers consist of lowercase * alphanumeric characters and the underscore. * @param {String} name The name to convert. * @returns {String} Identifier. */ function makeIdentifier(name) { // Lowercase and replace consecutive invalid characters with underscores. return name.toLowerCase().replace(nonIdentRegex, '_'); } exports.makeIdentifier = makeIdentifier; /** * Copies a file, returning a promise that is resolved (with no value) when the copy is complete. * TODO This needs a unittest. */ function copyFile(sourcePath, destPath) { var sourceStream, destStream; return new Promise(function(resolve, reject) { sourceStream = fs.createReadStream(sourcePath); destStream = fs.createWriteStream(destPath); sourceStream.on('error', reject); destStream.on('error', reject); destStream.on('finish', resolve); sourceStream.pipe(destStream); }) .finally(function() { if (destStream) { destStream.destroy(); } if (sourceStream) { sourceStream.destroy(); } }); } exports.copyFile = copyFile; /** * Helper for creating numbered files. Tries to call creator() with name, then (name + separator + * "2") and so on with incrementing numbers, as long as the promise returned by creator() is * rejected with err.code of 'EEXIST'. Creator() must return a promise. * @param {String} name The first name to try. * @param {String} separator The separator between name and appended numbers. * @param {Function} creator The function to call with successive names. Must return a promise. * @param {Number} startNum Optional number to start with; omit to try an unnumbered name first. * @returns {Promise} Promise for the first name for which creator() succeeded. */ function createNumbered(name, separator, creator, startNum) { var fullName = name + (startNum === undefined ? '' : separator + startNum); var nextNum = (startNum === undefined ? 2 : startNum + 1); return creator(fullName) .then(() => fullName) .catch(function(err) { if (err.cause && err.cause.code !== 'EEXIST') throw err; return createNumbered(name, separator, creator, nextNum); }); } exports.createNumbered = createNumbered; /** * An easier-to-use alternative to createNumbered. Pass in a template string containing the * special token "{NUM}". It will first call creator() with "{NUM}" removed, then with "{NUM}" * replaced by "-2", "-3", etc, until creator() succeeds, and will return the value for which it * succeeded. */ function createNumberedTemplate(template, creator) { const [prefix, suffix] = template.split("{NUM}"); if (typeof prefix !== "string" || typeof suffix !== "string") { throw new Error(`createNumberedTemplate: invalid template ${template}`); } return createNumbered(prefix, "-", (uniqPrefix) => creator(uniqPrefix + suffix)) .then((uniqPrefix) => uniqPrefix + suffix); } exports.createNumberedTemplate = createNumberedTemplate; /** * Creates a new file, failing if the path already exists. * @param {String} path: The path to try creating. * @returns {Promise} Resolved if the path was created, rejected if it already existed (with * err.cause.code === EEXIST) or if there was another error creating it. */ function createExclusive(path) { return fs.openAsync(path, 'wx').then(fd => fs.closeAsync(fd)); } exports.createExclusive = createExclusive; /** * Returns the canonicalized absolute path for the given path, using fs.realpath, but allowing * non-existent paths. In case of non-existent path, the longest existing prefix is resolved and * the rest kept unchanged. * @param {String} path: Path to resolve. * @return {Promise:String} Promise for the resolved path. */ function realPath(path) { return fs.realpathAsync(path) .catch(() => realPath(fsPath.dirname(path)) .then(dir => fsPath.join(dir, fsPath.basename(path))) ); } exports.realPath = realPath; /** * Returns a promise that resolves to true or false based on whether the path exists. If other * errors occur, this promise may still be rejected. */ function pathExists(path) { return fs.accessAsync(path) .then(() => true) .catch({code: 'ENOENT'}, () => false) .catch({code: 'ENOTDIR'}, () => false); } exports.pathExists = pathExists; /** * Returns a promise that resolves to true or false based on whether the two paths point to the * same file. If errors occur, this promise may be rejected. */ function isSameFile(path1, path2) { return Promise.join(fs.lstatAsync(path1), fs.lstatAsync(path2), (stat1, stat2) => { if (stat1.dev === stat2.dev && stat1.ino === stat2.ino) { return true; } return false; }) .catch({code: 'ENOENT'}, () => false) .catch({code: 'ENOTDIR'}, () => false); } exports.isSameFile = isSameFile;