watch-project
Version:
watch directory change, emit events including create, change, mv, remove, mkdir, mvdir, rmdir as exactly as there is
701 lines (652 loc) • 20.2 kB
JavaScript
/**
* Module dependencies.
*/
var fs = require('fs')
, path = require('path')
, md5 = require('MD5')
, chokidar = require('chokidar')
, BETA = process.env.SYNC_BETA;
var DEBUG// = true;
;
var opts;
/**
* used to check the existence of a file and return it's type
* @param fpath
* @returns {String | Boolean}
*/
var type = function (fpath) {
if (fs.existsSync(fpath)) {
var state = fs.statSync(fpath);
if (state.isFile()) {
return 'File'
} else if (state.isDirectory()) {
return 'Dir'
} else {
return false;
}
}
return false;
};
/**
* generate md5 string (the id of a directory or a file) of a file or a directory
* @param fp
* @param opt_t optional param describe the filetype of the filepath
* @returns {String | Boolean}
*/
var genID = function (fp, opt_t) {
var t = opt_t || type(fp);
if (t == 'Dir') {
try {
return md5(fs.readdirSync(fp));
} catch (e) {
return false;
}
} else if (t == 'File') {
try {
return md5(fs.readFileSync(fp));
} catch (e) {
return false;
}
}
return false;
};
/**
* @desc
* - push files into project information center [memo] by method fileListHash.addFile
* - push dirs into project information center [memo] by method fileListHash.addDir
* - support callback to be called on found dirs, which is used to get a DIR's information recursively
* @param {string} parent dir path to be scanned
* @param {function} onDir callback to be called when find sub-directory
* @param {object} filter used to ignore some file
*/
var genDirInfo = function (parent, onDir) {
if (type(parent) == 'Dir') {
var fileList = fs.readdirSync(parent);
//将文件夹存入hash
fileListHash.addDir(parent, fileList);
fileList && fileList.forEach(function (f) {
if (!opts.withHidden && f[0] == '.') {
//filter hidden directory and files
return
}
var sdir = path.join(parent, f);
if (type(sdir) == 'Dir') {
onDir.call(null, sdir)
} else {
//文件夹会调用watch,并自动填加该文件夹到文件hash中去
//普通文件则需要在此处添加到hash中去
fileListHash.addFile(sdir);
}
});
}
};
/**
* Mixing object properties.
*/
var mixin = function () {
var mix = {};
[].forEach.call(arguments, function (arg) {
for (var name in arg) {
if (arg.hasOwnProperty(name)) {
mix[name] = arg[name];
}
}
});
return mix;
};
var memo = {file:{}, dir: {}};
/**
* A container for memorizing names of files or directories.
*/
var fileListHash = function (memo) {
var fn;
return {
/**
* register callback passed to function module, and automatically watch new directory created during watch
* @param cb origin callback
* @param options origin option
*/
registerCB: function (cb, options) {
if(options.stable){
// use "chokidar", no need to watch changed dir
fn = function(event){
var f = event.filename;
if (!fileListHash.has(f) && type(f) == 'Dir') {
//only need to update information of the changed new directory
collectDirInfo(f);
}
cb.call(null, event);
}
}else{
// use "fs.watch", need to watch changed dir
fn = function (event) {
var f = event.filename;
if (!fileListHash.has(f) && type(f) == 'Dir') {
//watch directory doesn't under watch
watchDir(f);
}
cb.call(null, event);
}
}
},
ready: function(){
this.readyHandler && this.readyHandler();
},
export: function () {
return JSON.stringify(memo);
},
addDir: (function(){
if (process.platform == 'win32' && BETA){
return function (dp, fileList) {
if (!memo.dir[dp]) {
memo.dir[dp] = {
//type: 'Dir',
id: md5(fileList),
files: fileList.toString()
}
}
return this;
}
}else {
return function(dp, fileList){
if (!memo.dir[dp]) {
memo.dir[dp] = {
//type: 'Dir',
id: md5(fileList)
}
}
return this;
}
}
})(),
addFile: function (fp, id) {
if (!memo.file[fp]) {
if (!id) {
try {
id = md5(fs.readFileSync(fp));
} catch (e) {
id = null;
}
}
memo.file[fp] = {
//type: 'File',
id: id
};
}
return this;
},
removeFile: function (name) {
delete memo.file[name];
return this;
},
removeDir: function (name) {
delete memo.dir[name];
var l = name.length;
//移除该目录下面检测的文件
Object.keys(memo.file).forEach(function (f) {
if (f.slice(0, l) == name) {
delete memo.file[f];
}
;
});
//移除该目录下面监测的文件夹
Object.keys(memo.dir).forEach(function (f) {
if (f.slice(0, l) == name) {
delete memo.dir[f];
}
});
return this;
},
//update id of an directory when it's contained files and directories moved[deleted, created], renamed
updateParentDirID: (function(){
if (process.platform == 'win32' && BETA){
return function (fp) {
var parent = path.dirname(fp),
did = genID(parent, 'Dir');
memo.dir[parent] ? memo.dir[parent].id = did : "";
memo.dir[parent] ? memo.dir[parent].files = fs.readdirSync(parent).toString() : "";
return this;
}
}else{
return function (fp) {
var parent = path.dirname(fp),
did = genID(parent, 'Dir');
memo.dir[parent] ? memo.dir[parent].id = did : "";
return this;
}
}
})(),
//update id of an file when it's modified
updateFileID: function (fp) {
var fid = genID(fp, 'File');
memo.file[fp] ? memo.file[fp].id = fid : "";
return this;
},
has: function (name) {
return memo.dir[name] || memo.file[name];
},
originID: function (name) {
var record = memo.dir[name] || memo.file[name];
return record && record.id;
},
// only needed under 'windows' platform for compatibility
originContentListHash: function(name){
var listStr = memo.dir[name].files;
// "".split(',') will result to [""]
if (listStr.length==0){
return {}
}
var retObj = {}
listStr.split(',').forEach(function(item){
retObj[item] = true;
});
return retObj;
},
currentContentListHash: function(path) {
var fileList = fs.readdirSync(path);
var retObj = {};
fileList.forEach(function(item){
retObj[item] = true;
});
return retObj;
},
originFileType: function (name) {
return memo.dir[name] ? 'Dir' : 'File';
},
//container for stash delete event because it's triggered before create/mkdir event
deleteList: {},
rmdirList: {},
genEvent: function (fp, sim) {
// if (sim){
// console.error(fp);
// }else{
// console.log (fp);
// }
var currentFT = type(fp);
if (this.has(fp)) {
//原先有该文件(修改,删除)
if (currentFT == "File") {
//同名文件触发变化事件 —— 修改
//需要更新md5值
var oid = memo.file[fp].id; // 缓存变化前的MD5值
this.updateFileID(fp).updateParentDirID(fp); //更新文件状态,当前memo记录的是新MD5值
fn.call(null, {type: 'change', filename: fp, oid: oid, nid: memo.file[fp].id});
} else if (currentFT == "Dir") {
/**
* Hacks for windous
*/
// 只有在windous系统下才会出现这种情况(子文件内容改变)
// 检查一下该目录下文件内容的变化,并派发相应的事件
//console.log(memo)
var originContentListHash = this.originContentListHash(fp);
var currentContentListHash = this.currentContentListHash(fp);
var deleted = {}, created = {};
// [handle remove type !must be first ] remove un changed files & dirs
for (var key in originContentListHash){
if (!currentContentListHash[key]){
deleted[key] = true;
}
}
// [handle insert type ! must after delete event for rename]
for (var key in currentContentListHash){
if (!originContentListHash[key]){
created[key] = true
}
}
// simulate file delete event
for(var item in deleted){
simulateFp = fp+path.sep+path.basename(item);
this.genEvent(simulateFp, 'sim');
};
// simulate file create event
for(var item in created){
simulateFp = fp+path.sep+path.basename(item);
this.genEvent(simulateFp, 'sim');
};
} else { // not exist
//当移动文件时,会先后触发原文件的删除,新文件的创建事件
//将删除事件缓存起来,确定不是重命名、移动事件时再触发
if (this.originFileType(fp) == "File") {
this.deleteList[this.originID(fp)] = fp;
//不论是删除还是重命名,都需要第一时间重载父目录的hash值
this.removeFile(fp).updateParentDirID(fp);
} else {
//console.log(this.originFileType(fp));
//如果是删除文件夹,会先触发子文件的删除事件,因此上面的removeFile(fp).updateParentDirID(fp)会导致originID(fp)为false,
// 因为不存在了,移动文件夹时因为不会触发子文件的删除事件,根目录是最后被触发的,所以最后的rmdirList中存放的就是待删除的根目录
this.rmdirList[this.originID(fp)] = fp;
this.removeDir(fp).updateParentDirID(fp);
}
}
} else {
//原先没有该文件(新建,重命名|移动))
var id = genID(fp);
if (currentFT == "File") {
if (this.deleteList[id]) {
//通过md5判断为同一文件,因此是移动事件
//由于文件不会触发重新watch,因此需要手动添加最新路径,同时更新目录文件
this.addFile(fp, id).updateParentDirID(fp);
fn.call(null, {type: 'mvfile', filename: fp, oname: this.deleteList[id], oid: id});
delete this.deleteList[id];
} else {
//没有该文件hash值的记录,为新建文件
this.addFile(fp, id).updateParentDirID(fp);
fn.call(null, {type: 'create', filename: fp});
}
} else if (currentFT == "Dir") {
if (this.rmdirList[id]) {
//移动文件夹,会调用watch,不需要手动添加最新路径和更新父目录(在fn中完成))
fn.call(null, {type: 'mvdir', filename: fp, oname: this.rmdirList[id], oid: id});
delete this.rmdirList[id];
} else {
fn.call(null, {type: 'mkdir', filename: fp});
}
} else {
//一些由编辑器产生的临时文件
return false;
}
}
},
/**
* merge sub-directory rmdir event to the top directory
*/
filterRemovedDir: function () {
var removedDir = this.rmdirList["false"],
l,
prefix,
self = this;
if (removedDir) {
delete this.rmdirList["false"];
l = removedDir.length;
//删除一个目录时,该目录下的文件删除事件先触发,导致更新了父文件的id,而在处理时,父文件已经不存在了,
//因此其id为false,生成的rmdirList永远是被删除的根目录{false, 'xx/oo'};,子目录先触发所以被覆盖了!,
//过滤掉该文件夹下的所有文件的删除事件,无需考虑目录,因为子目录都被覆盖了
Object.keys(this.rmdirList).forEach(function (id) {
prefix = self.rmdirList[id].slice(0, l);
if (prefix == removedDir) {
delete self.rmdirList[id];
}
});
Object.keys(this.deleteList).forEach(function (id) {
prefix = self.deleteList[id].slice(0, l);
if (prefix == removedDir) {
delete self.deleteList[id];
}
});
this.rmdirList["false"] = removedDir;
}
},
/**
* emit the event stashed for estimating MV event( including rename, move)
*/
clear: function () {
//先处理目录,过滤文件的删除事件
this.filterRemovedDir();
for (var id in this.rmdirList) {
fn.call(null, {type: 'rmdir', filename: this.rmdirList[id], oname: this.rmdirList[id], oid: id});
}
for (var id in this.deleteList) {
//相应的更新事件已经 removeFile|removeDir中完成了
fn.call(null, {type: 'delete', filename: this.deleteList[id], oname: this.deleteList[id], oid: id});
}
this.deleteList = {};
this.rmdirList = {};
DEBUG ?
setTimeout(function () {
console.log('>>>>>>>>>>>>>');
console.log(memo);
console.log('>>>>>>>>>>>>>');
}, 1000) : "";
}
};
}(memo);
/**f7507cfcb2844248ace78e7a1' },
* A Container for storing unique and valid filenames.
*/
var fileNameCache = function (cache) {
return {
push: function (name) {
cache[name] = 1;
return this;
},
process: (function(){
if (process.platform == 'win32' && BETA ){
// win32 platform, adjust events order by replace first file path (target path) with directory
// if it's parent is also in the change list!
return function(){
// adjust event's order
var i = 0;
var changeList = [];
for (var item in cache){
changeList.push(item)
if(++i > 3){
break;
}
}
var first = changeList[0];
var matchKey = path.dirname(first);
// check if first changed path's dirname is in the list
if (cache[matchKey]){
// console.info('re order ....')
// console.log (changeList)
// exchange the second and third item
if (matchKey == changeList[2]){
fileListHash.genEvent(changeList[1]);
if (changeList[2]){
// rename events don't have third item
fileListHash.genEvent(changeList[2]);
}
}else{
if (changeList[2]){
// rename events don't have third item
fileListHash.genEvent(changeList[2]);
}
fileListHash.genEvent(changeList[1]);
}
}else{
// console.info('origin ....')
for (var fp in cache) {
fileListHash.genEvent(fp);
}
}
//console.log (cache);
//
this._clear();
return this;
}
}else{
return function () {
for (var fp in cache) {
fileListHash.genEvent(fp);
}
this._clear();
return this;
}
}
})(),
_process: function () {
for (var fp in cache) {
fileListHash.genEvent(fp);
}
this._clear();
return this;
},
_clear: function () {
fileListHash.clear();
cache = {};
return this;
}
};
}({});
/**
* Abstracting the way of avoiding duplicate function call.
*/
var worker = function () {
var free = true;
return {
busydoing: function (cb) {
if (free) {
free = false;
cb.call();
}
},
free: function () {
free = true;
}
}
}();
/**
* Delay function call and ignore invalid filenames.
*/
var normalizeCall = function (fname) {
// Store each name of the modifying or temporary files generated by an editor.
if(fname[0] == '.'){
//filter hidden directory and file
return;
}
fileNameCache.push(fname);
worker.busydoing(function () {
// A heuristic delay of the write-to-file process.
setTimeout(function () {
// When the write-to-file process is done, send all filtered filenames
// to the callback function and call it.
fileNameCache.process();
worker.free();
}, 100);
});
};
/**
* Catch exception on Windows when deleting a directory.
*/
var catchException = function () {
};
/**
* Option handler for the `watch` function.
*/
var handleOptions = function (origin, defaultOptions) {
var f = function(){
var args = [].slice.call(arguments);
if (Object.prototype.toString.call(args[1]) === '[object Function]') {
args[2] = args[1];
}
if (!Array.isArray(args[0])) {
args[0] = [args[0]];
}
//overwrite default options
args[1] = mixin(defaultOptions, args[1]);
//handle multiple files.
args[0].forEach(function (path) {
origin.apply(null, [path].concat(args.slice(1)));
});
//
f.emit = function(){
args[2].apply(null, [].slice.call(arguments));
};
};
//add some public function to the wrapper
f.status = function () {
return fileListHash.export();
};
f.ready = function(fn){
fileListHash.readyHandler = fn;
};
return f;
};
/**
* Watch a file or a directory (recursively by default).
*
* @param {String} dir
* @options {Object} options
* @param {Function} cb
*
* Options:
* `recursive`: Watch it recursively or not (defaults to true).
* `followSymLinks`: Follow symbolic links or not (defaults to false).
* `maxSymLevel`: The max number of following symbolic links (defaults to 1).
*
* Example:
*
* watch('fpath', {recursive: true}, function(file) {
* console.log(file, ' changed');
* });
*/
function init(dir, options, cb) {
if (!arguments.callee.init) {
opts = options;
arguments.callee.init = true;
fileListHash.registerCB(cb, options);
// 会导致多重复文件夹失败
//fpath = path.basename(fpath);
}
//只支持文件夹监测
if (type(dir) == 'Dir') {
//use path.basename() to convert ./test to test which is equal but beautiful
//watchDir(dir);
if(options.stable){
// mac and old node
watchDirStable(dir);
collectDirInfo(dir);
}else{
BETA = true;
watchDir(dir);
}
fileListHash.ready();
}
};
/**
*
*/
function collectDirInfo(dir){
genDirInfo(dir, function(subdir){
collectDirInfo(subdir);
});
}
/**
* use "chokidar" module to replace the origin fs.watch since it's not work well on dlo version of OSX and nodejs
* @param dir
*/
function watchDirStable(dir){
var watcher;
if (opts.withHidden) {
watcher = chokidar.watch(dir, {
persistent: true,
ignoreInitial: true
});
} else {
var ignoreReg;
if (path.sep === '\\'){
ignoreReg = /\\\./;
}else{
ignoreReg = /\/\./;
};
watcher = chokidar.watch(dir, {
ignored: ignoreReg,
persistent: true,
ignoreInitial: true
});
}
watcher.on('all', function(e, f){
normalizeCall(f);
});
}
/**
* call fs.watch(dir) recursively since the official API is not recursive
* used for latest version of node and OSX newer then 10.7
* @param dir
*/
function watchDir(dir){
fs.watch(dir, function(evt, fname){
//TODO 被watch的目录重命名时,也会触发rename事件,导致一个目录的变化被父目录和自身同时捕获
if(fname){
normalizeCall(path.join(dir, fname));
}
}).on('error', catchException);
genDirInfo(dir, function(dir){
// Recursively watch its sub-directories, store the files and directories information into hash
watchDir(dir);
});
}
/**
* Set default options and expose.
*/
module.exports = handleOptions(init);