| /** |
| Licensed to the Apache Software Foundation (ASF) under one |
| or more contributor license agreements. See the NOTICE file |
| distributed with this work for additional information |
| regarding copyright ownership. The ASF licenses this file |
| to you under the Apache License, Version 2.0 (the |
| "License"); you may not use this file except in compliance |
| with the License. You may obtain a copy of the License at |
| |
| http://www.apache.org/licenses/LICENSE-2.0 |
| |
| Unless required by applicable law or agreed to in writing, |
| software distributed under the License is distributed on an |
| "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY |
| KIND, either express or implied. See the License for the |
| specific language governing permissions and limitations |
| under the License. |
| */ |
| |
| 'use strict'; |
| |
| var fs = require('fs'); |
| var path = require('path'); |
| var shell = require('shelljs'); |
| var minimatch = require('minimatch'); |
| |
| /** |
| * Logging callback used in the FileUpdater methods. |
| * @callback loggingCallback |
| * @param {string} message A message describing a single file update operation. |
| */ |
| |
| /** |
| * Updates a target file or directory with a source file or directory. (Directory updates are |
| * not recursive.) Stats for target and source items must be passed in. This is an internal |
| * helper function used by other methods in this module. |
| * |
| * @param {?string} sourcePath Source file or directory to be used to update the |
| * destination. If the source is null, then the destination is deleted if it exists. |
| * @param {?fs.Stats} sourceStats An instance of fs.Stats for the source path, or null if |
| * the source does not exist. |
| * @param {string} targetPath Required destination file or directory to be updated. If it does |
| * not exist, it will be created. |
| * @param {?fs.Stats} targetStats An instance of fs.Stats for the target path, or null if |
| * the target does not exist. |
| * @param {Object} [options] Optional additional parameters for the update. |
| * @param {string} [options.rootDir] Optional root directory (such as a project) to which target |
| * and source path parameters are relative; may be omitted if the paths are absolute. The |
| * rootDir is always omitted from any logged paths, to make the logs easier to read. |
| * @param {boolean} [options.all] If true, all files are copied regardless of last-modified times. |
| * Otherwise, a file is copied if the source's last-modified time is greather than or |
| * equal to the target's last-modified time, or if the file sizes are different. |
| * @param {loggingCallback} [log] Optional logging callback that takes a string message |
| * describing any file operations that are performed. |
| * @return {boolean} true if any changes were made, or false if the force flag is not set |
| * and everything was up to date |
| */ |
| function updatePathWithStats (sourcePath, sourceStats, targetPath, targetStats, options, log) { |
| var updated = false; |
| |
| var rootDir = (options && options.rootDir) || ''; |
| var copyAll = (options && options.all) || false; |
| |
| var targetFullPath = path.join(rootDir || '', targetPath); |
| |
| if (sourceStats) { |
| var sourceFullPath = path.join(rootDir || '', sourcePath); |
| |
| if (targetStats) { |
| // The target exists. But if the directory status doesn't match the source, delete it. |
| if (targetStats.isDirectory() && !sourceStats.isDirectory()) { |
| log('rmdir ' + targetPath + ' (source is a file)'); |
| shell.rm('-rf', targetFullPath); |
| targetStats = null; |
| updated = true; |
| } else if (!targetStats.isDirectory() && sourceStats.isDirectory()) { |
| log('delete ' + targetPath + ' (source is a directory)'); |
| shell.rm('-f', targetFullPath); |
| targetStats = null; |
| updated = true; |
| } |
| } |
| |
| if (!targetStats) { |
| if (sourceStats.isDirectory()) { |
| // The target directory does not exist, so it should be created. |
| log('mkdir ' + targetPath); |
| shell.mkdir('-p', targetFullPath); |
| updated = true; |
| } else if (sourceStats.isFile()) { |
| // The target file does not exist, so it should be copied from the source. |
| log('copy ' + sourcePath + ' ' + targetPath + (copyAll ? '' : ' (new file)')); |
| shell.cp('-f', sourceFullPath, targetFullPath); |
| updated = true; |
| } |
| } else if (sourceStats.isFile() && targetStats.isFile()) { |
| // The source and target paths both exist and are files. |
| if (copyAll) { |
| // The caller specified all files should be copied. |
| log('copy ' + sourcePath + ' ' + targetPath); |
| shell.cp('-f', sourceFullPath, targetFullPath); |
| updated = true; |
| } else { |
| // Copy if the source has been modified since it was copied to the target, or if |
| // the file sizes are different. (The latter catches most cases in which something |
| // was done to the file after copying.) Comparison is >= rather than > to allow |
| // for timestamps lacking sub-second precision in some filesystems. |
| if (sourceStats.mtime.getTime() >= targetStats.mtime.getTime() || |
| sourceStats.size !== targetStats.size) { |
| log('copy ' + sourcePath + ' ' + targetPath + ' (updated file)'); |
| shell.cp('-f', sourceFullPath, targetFullPath); |
| updated = true; |
| } |
| } |
| } |
| } else if (targetStats) { |
| // The target exists but the source is null, so the target should be deleted. |
| if (targetStats.isDirectory()) { |
| log('rmdir ' + targetPath + (copyAll ? '' : ' (no source)')); |
| shell.rm('-rf', targetFullPath); |
| } else { |
| log('delete ' + targetPath + (copyAll ? '' : ' (no source)')); |
| shell.rm('-f', targetFullPath); |
| } |
| updated = true; |
| } |
| |
| return updated; |
| } |
| |
| /** |
| * Helper for updatePath and updatePaths functions. Queries stats for source and target |
| * and ensures target directory exists before copying a file. |
| */ |
| function updatePathInternal (sourcePath, targetPath, options, log) { |
| var rootDir = (options && options.rootDir) || ''; |
| var targetFullPath = path.join(rootDir, targetPath); |
| var targetStats = fs.existsSync(targetFullPath) ? fs.statSync(targetFullPath) : null; |
| var sourceStats = null; |
| |
| if (sourcePath) { |
| // A non-null source path was specified. It should exist. |
| var sourceFullPath = path.join(rootDir, sourcePath); |
| if (!fs.existsSync(sourceFullPath)) { |
| throw new Error('Source path does not exist: ' + sourcePath); |
| } |
| |
| sourceStats = fs.statSync(sourceFullPath); |
| |
| // Create the target's parent directory if it doesn't exist. |
| var parentDir = path.dirname(targetFullPath); |
| if (!fs.existsSync(parentDir)) { |
| shell.mkdir('-p', parentDir); |
| } |
| } |
| |
| return updatePathWithStats(sourcePath, sourceStats, targetPath, targetStats, options, log); |
| } |
| |
| /** |
| * Updates a target file or directory with a source file or directory. (Directory updates are |
| * not recursive.) |
| * |
| * @param {?string} sourcePath Source file or directory to be used to update the |
| * destination. If the source is null, then the destination is deleted if it exists. |
| * @param {string} targetPath Required destination file or directory to be updated. If it does |
| * not exist, it will be created. |
| * @param {Object} [options] Optional additional parameters for the update. |
| * @param {string} [options.rootDir] Optional root directory (such as a project) to which target |
| * and source path parameters are relative; may be omitted if the paths are absolute. The |
| * rootDir is always omitted from any logged paths, to make the logs easier to read. |
| * @param {boolean} [options.all] If true, all files are copied regardless of last-modified times. |
| * Otherwise, a file is copied if the source's last-modified time is greather than or |
| * equal to the target's last-modified time, or if the file sizes are different. |
| * @param {loggingCallback} [log] Optional logging callback that takes a string message |
| * describing any file operations that are performed. |
| * @return {boolean} true if any changes were made, or false if the force flag is not set |
| * and everything was up to date |
| */ |
| function updatePath (sourcePath, targetPath, options, log) { |
| if (sourcePath !== null && typeof sourcePath !== 'string') { |
| throw new Error('A source path (or null) is required.'); |
| } |
| |
| if (!targetPath || typeof targetPath !== 'string') { |
| throw new Error('A target path is required.'); |
| } |
| |
| log = log || function (message) { }; |
| |
| return updatePathInternal(sourcePath, targetPath, options, log); |
| } |
| |
| /** |
| * Updates files and directories based on a mapping from target paths to source paths. Targets |
| * with null sources in the map are deleted. |
| * |
| * @param {Object} pathMap A dictionary mapping from target paths to source paths. |
| * @param {Object} [options] Optional additional parameters for the update. |
| * @param {string} [options.rootDir] Optional root directory (such as a project) to which target |
| * and source path parameters are relative; may be omitted if the paths are absolute. The |
| * rootDir is always omitted from any logged paths, to make the logs easier to read. |
| * @param {boolean} [options.all] If true, all files are copied regardless of last-modified times. |
| * Otherwise, a file is copied if the source's last-modified time is greather than or |
| * equal to the target's last-modified time, or if the file sizes are different. |
| * @param {loggingCallback} [log] Optional logging callback that takes a string message |
| * describing any file operations that are performed. |
| * @return {boolean} true if any changes were made, or false if the force flag is not set |
| * and everything was up to date |
| */ |
| function updatePaths (pathMap, options, log) { |
| if (!pathMap || typeof pathMap !== 'object' || Array.isArray(pathMap)) { |
| throw new Error('An object mapping from target paths to source paths is required.'); |
| } |
| |
| log = log || function (message) { }; |
| |
| var updated = false; |
| |
| // Iterate in sorted order to ensure directories are created before files under them. |
| Object.keys(pathMap).sort().forEach(function (targetPath) { |
| var sourcePath = pathMap[targetPath]; |
| updated = updatePathInternal(sourcePath, targetPath, options, log) || updated; |
| }); |
| |
| return updated; |
| } |
| |
| /** |
| * Updates a target directory with merged files and subdirectories from source directories. |
| * |
| * @param {string|string[]} sourceDirs Required source directory or array of source directories |
| * to be merged into the target. The directories are listed in order of precedence; files in |
| * directories later in the array supersede files in directories earlier in the array |
| * (regardless of timestamps). |
| * @param {string} targetDir Required destination directory to be updated. If it does not exist, |
| * it will be created. If it exists, newer files from source directories will be copied over, |
| * and files missing in the source directories will be deleted. |
| * @param {Object} [options] Optional additional parameters for the update. |
| * @param {string} [options.rootDir] Optional root directory (such as a project) to which target |
| * and source path parameters are relative; may be omitted if the paths are absolute. The |
| * rootDir is always omitted from any logged paths, to make the logs easier to read. |
| * @param {boolean} [options.all] If true, all files are copied regardless of last-modified times. |
| * Otherwise, a file is copied if the source's last-modified time is greather than or |
| * equal to the target's last-modified time, or if the file sizes are different. |
| * @param {string|string[]} [options.include] Optional glob string or array of glob strings that |
| * are tested against both target and source relative paths to determine if they are included |
| * in the merge-and-update. If unspecified, all items are included. |
| * @param {string|string[]} [options.exclude] Optional glob string or array of glob strings that |
| * are tested against both target and source relative paths to determine if they are excluded |
| * from the merge-and-update. Exclusions override inclusions. If unspecified, no items are |
| * excluded. |
| * @param {loggingCallback} [log] Optional logging callback that takes a string message |
| * describing any file operations that are performed. |
| * @return {boolean} true if any changes were made, or false if the force flag is not set |
| * and everything was up to date |
| */ |
| function mergeAndUpdateDir (sourceDirs, targetDir, options, log) { |
| if (sourceDirs && typeof sourceDirs === 'string') { |
| sourceDirs = [ sourceDirs ]; |
| } else if (!Array.isArray(sourceDirs)) { |
| throw new Error('A source directory path or array of paths is required.'); |
| } |
| |
| if (!targetDir || typeof targetDir !== 'string') { |
| throw new Error('A target directory path is required.'); |
| } |
| |
| log = log || function (message) { }; |
| |
| var rootDir = (options && options.rootDir) || ''; |
| |
| var include = (options && options.include) || [ '**' ]; |
| if (typeof include === 'string') { |
| include = [ include ]; |
| } else if (!Array.isArray(include)) { |
| throw new Error('Include parameter must be a glob string or array of glob strings.'); |
| } |
| |
| var exclude = (options && options.exclude) || []; |
| if (typeof exclude === 'string') { |
| exclude = [ exclude ]; |
| } else if (!Array.isArray(exclude)) { |
| throw new Error('Exclude parameter must be a glob string or array of glob strings.'); |
| } |
| |
| // Scan the files in each of the source directories. |
| var sourceMaps = sourceDirs.map(function (sourceDir) { |
| return path.join(rootDir, sourceDir); |
| }).map(function (sourcePath) { |
| if (!fs.existsSync(sourcePath)) { |
| throw new Error('Source directory does not exist: ' + sourcePath); |
| } |
| return mapDirectory(rootDir, path.relative(rootDir, sourcePath), include, exclude); |
| }); |
| |
| // Scan the files in the target directory, if it exists. |
| var targetMap = {}; |
| var targetFullPath = path.join(rootDir, targetDir); |
| if (fs.existsSync(targetFullPath)) { |
| targetMap = mapDirectory(rootDir, targetDir, include, exclude); |
| } |
| |
| var pathMap = mergePathMaps(sourceMaps, targetMap, targetDir); |
| |
| var updated = false; |
| |
| // Iterate in sorted order to ensure directories are created before files under them. |
| Object.keys(pathMap).sort().forEach(function (subPath) { |
| var entry = pathMap[subPath]; |
| updated = updatePathWithStats( |
| entry.sourcePath, |
| entry.sourceStats, |
| entry.targetPath, |
| entry.targetStats, |
| options, |
| log) || updated; |
| }); |
| |
| return updated; |
| } |
| |
| /** |
| * Creates a dictionary map of all files and directories under a path. |
| */ |
| function mapDirectory (rootDir, subDir, include, exclude) { |
| var dirMap = { '': { subDir: subDir, stats: fs.statSync(path.join(rootDir, subDir)) } }; |
| mapSubdirectory(rootDir, subDir, '', include, exclude, dirMap); |
| return dirMap; |
| |
| function mapSubdirectory (rootDir, subDir, relativeDir, include, exclude, dirMap) { |
| var itemMapped = false; |
| var items = fs.readdirSync(path.join(rootDir, subDir, relativeDir)); |
| |
| items.forEach(function (item) { |
| var relativePath = path.join(relativeDir, item); |
| if (!matchGlobArray(relativePath, exclude)) { |
| // Stats obtained here (required at least to know where to recurse in directories) |
| // are saved for later, where the modified times may also be used. This minimizes |
| // the number of file I/O operations performed. |
| var fullPath = path.join(rootDir, subDir, relativePath); |
| var stats = fs.statSync(fullPath); |
| |
| if (stats.isDirectory()) { |
| // Directories are included if either something under them is included or they |
| // match an include glob. |
| if (mapSubdirectory(rootDir, subDir, relativePath, include, exclude, dirMap) || |
| matchGlobArray(relativePath, include)) { |
| dirMap[relativePath] = { subDir: subDir, stats: stats }; |
| itemMapped = true; |
| } |
| } else if (stats.isFile()) { |
| // Files are included only if they match an include glob. |
| if (matchGlobArray(relativePath, include)) { |
| dirMap[relativePath] = { subDir: subDir, stats: stats }; |
| itemMapped = true; |
| } |
| } |
| } |
| }); |
| return itemMapped; |
| } |
| |
| function matchGlobArray (path, globs) { |
| return globs.some(function (elem) { |
| return minimatch(path, elem, {dot: true}); |
| }); |
| } |
| } |
| |
| /** |
| * Merges together multiple source maps and a target map into a single mapping from |
| * relative paths to objects with target and source paths and stats. |
| */ |
| function mergePathMaps (sourceMaps, targetMap, targetDir) { |
| // Merge multiple source maps together, along with target path info. |
| // Entries in later source maps override those in earlier source maps. |
| // Target stats will be filled in below for targets that exist. |
| var pathMap = {}; |
| sourceMaps.forEach(function (sourceMap) { |
| Object.keys(sourceMap).forEach(function (sourceSubPath) { |
| var sourceEntry = sourceMap[sourceSubPath]; |
| pathMap[sourceSubPath] = { |
| targetPath: path.join(targetDir, sourceSubPath), |
| targetStats: null, |
| sourcePath: path.join(sourceEntry.subDir, sourceSubPath), |
| sourceStats: sourceEntry.stats |
| }; |
| }); |
| }); |
| |
| // Fill in target stats for targets that exist, and create entries |
| // for targets that don't have any corresponding sources. |
| Object.keys(targetMap).forEach(function (subPath) { |
| var entry = pathMap[subPath]; |
| if (entry) { |
| entry.targetStats = targetMap[subPath].stats; |
| } else { |
| pathMap[subPath] = { |
| targetPath: path.join(targetDir, subPath), |
| targetStats: targetMap[subPath].stats, |
| sourcePath: null, |
| sourceStats: null |
| }; |
| } |
| }); |
| |
| return pathMap; |
| } |
| |
| module.exports = { |
| updatePath: updatePath, |
| updatePaths: updatePaths, |
| mergeAndUpdateDir: mergeAndUpdateDir |
| }; |